erosolar-cli 1.7.269 → 1.7.270

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 +11 -7
  63. package/dist/shell/interactiveShell.d.ts.map +1 -1
  64. package/dist/shell/interactiveShell.js +195 -157
  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 -158
  74. package/dist/shell/terminalInput.d.ts.map +1 -1
  75. package/dist/shell/terminalInput.js +476 -803
  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 +23 -0
  97. package/dist/ui/display.d.ts.map +1 -1
  98. package/dist/ui/display.js +137 -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,37 @@ 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
+ // Track next content row in scroll region (banner flows up as content pushes from below)
90
+ nextContentRow = 1;
91
+ thinkingModeLabel = null;
94
92
  editMode = 'display-edits';
95
93
  verificationEnabled = true;
96
94
  autoContinueEnabled = false;
97
95
  verificationHotkey = 'alt+v';
98
96
  autoContinueHotkey = 'alt+c';
97
+ thinkingHotkey = '/thinking';
98
+ modelLabel = null;
99
+ providerLabel = null;
99
100
  // Output interceptor cleanup
100
101
  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)
102
+ // Streaming render throttle
103
+ lastStreamingRender = 0;
104
+ streamingRenderInterval = 250; // ms between renders during streaming
107
105
  streamingRenderTimer = null;
108
106
  constructor(writeStream = process.stdout, config = {}) {
109
107
  super();
110
108
  this.out = writeStream;
111
- // Use schema defaults for configuration consistency
112
109
  this.config = {
113
- maxLines: config.maxLines ?? DEFAULT_UI_CONFIG.inputArea.maxLines,
114
- maxLength: config.maxLength ?? DEFAULT_UI_CONFIG.inputArea.maxLength,
110
+ maxLines: config.maxLines ?? 1000,
111
+ maxLength: config.maxLength ?? 10000,
115
112
  maxQueueSize: config.maxQueueSize ?? 100,
116
- promptChar: config.promptChar ?? DEFAULT_UI_CONFIG.inputArea.promptChar,
117
- continuationChar: config.continuationChar ?? DEFAULT_UI_CONFIG.inputArea.continuationChar,
113
+ promptChar: config.promptChar ?? '> ',
114
+ continuationChar: config.continuationChar ?? '│ ',
118
115
  };
119
116
  }
120
117
  // ===========================================================================
@@ -193,11 +190,6 @@ export class TerminalInput extends EventEmitter {
193
190
  if (handled)
194
191
  return;
195
192
  }
196
- // Handle '?' for help hint (if buffer is empty)
197
- if (str === '?' && this.buffer.length === 0) {
198
- this.emit('showHelp');
199
- return;
200
- }
201
193
  // Insert printable characters
202
194
  if (str && !key?.ctrl && !key?.meta) {
203
195
  this.insertText(str);
@@ -206,462 +198,38 @@ export class TerminalInput extends EventEmitter {
206
198
  /**
207
199
  * Set the input mode
208
200
  *
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).
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.
212
203
  */
213
204
  setMode(mode) {
214
205
  const prevMode = this.mode;
215
206
  this.mode = mode;
216
207
  if (mode === 'streaming' && prevMode !== 'streaming') {
217
- // Track streaming start time for elapsed display
218
- this.streamingStartTime = Date.now();
219
- // Set up scroll region to reserve bottom for persistent input area
220
- this.pinnedTopRows = 0;
221
- this.reservedLines = 6; // status + model + divider + input + divider + controls
222
- // Enable scroll region: content scrolls above, bottom is reserved
208
+ // Keep scroll region active so status/prompt stay pinned while streaming
209
+ this.resetStreamingRenderThrottle();
223
210
  this.enableScrollRegion();
224
- // Initial render of bottom input area
225
- this.renderBottomInputArea();
226
- // Start timer to update bottom input area (updates elapsed time)
227
- this.streamingRenderTimer = setInterval(() => {
228
- if (this.mode === 'streaming') {
229
- this.updateStreamingStatus();
230
- this.renderBottomInputArea();
231
- }
232
- }, 1000);
233
211
  this.renderDirty = true;
212
+ this.render();
234
213
  }
235
214
  else if (mode !== 'streaming' && prevMode === 'streaming') {
236
- // Stop streaming render timer
237
- if (this.streamingRenderTimer) {
238
- clearInterval(this.streamingRenderTimer);
239
- this.streamingRenderTimer = null;
240
- }
241
- // Reset streaming time
242
- this.streamingStartTime = null;
243
- this.pinnedTopRows = 0;
244
- // Ensure no scroll region is active
245
- this.disableScrollRegion();
246
- // Reset flow mode tracking
247
- this.flowModeRenderedLines = 0;
248
- // Render input area using unified method (same as streaming, but normal mode)
249
- writeLock.withLock(() => {
250
- this.renderPinnedInputArea();
251
- }, 'terminalInput.streamingEnd');
252
- }
253
- }
254
- /**
255
- * Update streaming status label (called by timer)
256
- */
257
- updateStreamingStatus() {
258
- if (this.mode !== 'streaming' || !this.streamingStartTime)
259
- return;
260
- // Calculate elapsed time
261
- const elapsed = Date.now() - this.streamingStartTime;
262
- const seconds = Math.floor(elapsed / 1000);
263
- const minutes = Math.floor(seconds / 60);
264
- const secs = seconds % 60;
265
- // Format elapsed time
266
- let elapsedStr;
267
- if (minutes > 0) {
268
- elapsedStr = `${minutes}m ${secs}s`;
269
- }
270
- else {
271
- elapsedStr = `${secs}s`;
272
- }
273
- // Update streaming label
274
- this.streamingLabel = `Streaming ${elapsedStr}`;
275
- }
276
- /**
277
- * Render input area - unified for streaming and normal modes.
278
- *
279
- * In streaming mode: renders at absolute bottom, uses cursor save/restore
280
- * In normal mode: renders right after the banner (pinnedTopRows + 1)
281
- */
282
- renderPinnedInputArea() {
283
- const { rows, cols } = this.getSize();
284
- const maxWidth = Math.max(8, cols - 4);
285
- const divider = renderDivider(cols - 2);
286
- const isStreaming = this.mode === 'streaming';
287
- // Wrap buffer into display lines (multi-line support)
288
- const { lines, cursorLine, cursorCol } = this.wrapBuffer(maxWidth);
289
- const availableForContent = Math.max(1, rows - 5); // Reserve 5 lines for UI
290
- const maxVisible = Math.max(1, Math.min(this.config.maxLines, availableForContent));
291
- const displayLines = Math.min(lines.length, maxVisible);
292
- // Calculate display window (keep cursor visible)
293
- let startLine = 0;
294
- if (lines.length > displayLines) {
295
- startLine = Math.max(0, cursorLine - displayLines + 1);
296
- startLine = Math.min(startLine, lines.length - displayLines);
297
- }
298
- const visibleLines = lines.slice(startLine, startLine + displayLines);
299
- const adjustedCursorLine = cursorLine - startLine;
300
- // Calculate total height: status + (model info if set) + topDiv + input lines + bottomDiv + controls
301
- const hasModelInfo = !!this.modelInfo;
302
- const totalHeight = 4 + visibleLines.length + (hasModelInfo ? 1 : 0);
303
- // Save cursor position during streaming (so content flow resumes correctly)
304
- if (isStreaming) {
305
- this.write(ESC.SAVE);
306
- }
307
- this.write(ESC.HIDE);
308
- this.write(ESC.RESET);
309
- // Calculate start row based on mode:
310
- // - Streaming: absolute bottom (rows - totalHeight + 1)
311
- // - Normal: right after content (contentEndRow + 1)
312
- let currentRow;
313
- if (isStreaming) {
314
- currentRow = Math.max(1, rows - totalHeight + 1);
315
- }
316
- else {
317
- // In normal mode, render right after content
318
- // Use contentEndRow if set, otherwise use pinnedTopRows
319
- const contentRow = this.contentEndRow > 0 ? this.contentEndRow : this.pinnedTopRows;
320
- currentRow = Math.max(1, contentRow + 1);
321
- }
322
- let finalRow = currentRow;
323
- let finalCol = 3;
324
- // Clear from current position to end of screen to remove any "ghost" content
325
- this.write(ESC.TO(currentRow, 1));
326
- this.write(ESC.CLEAR_TO_END);
327
- // Status bar
328
- this.write(ESC.TO(currentRow, 1));
329
- this.write(isStreaming ? this.buildStreamingStatusBar(cols) : this.buildStatusBar(cols));
330
- currentRow++;
331
- // Model info line (if set) - displayed below status, above input
332
- if (hasModelInfo) {
333
- const { dim: DIM, reset: R } = UI_COLORS;
334
- this.write(ESC.TO(currentRow, 1));
335
- // Build model info with context usage
336
- let modelLine = `${DIM}${this.modelInfo}${R}`;
337
- if (this.contextUsage !== null) {
338
- const rem = Math.max(0, 100 - this.contextUsage);
339
- if (rem < 10)
340
- modelLine += ` ${UI_COLORS.red}⚠ ctx: ${rem}%${R}`;
341
- else if (rem < 25)
342
- modelLine += ` ${UI_COLORS.yellow}! ctx: ${rem}%${R}`;
343
- else
344
- modelLine += ` ${DIM}· ctx: ${rem}%${R}`;
345
- }
346
- this.write(modelLine);
347
- currentRow++;
348
- }
349
- // Top divider
350
- this.write(ESC.TO(currentRow, 1));
351
- this.write(divider);
352
- currentRow++;
353
- // Input lines with background styling
354
- for (let i = 0; i < visibleLines.length; i++) {
355
- this.write(ESC.TO(currentRow, 1));
356
- const line = visibleLines[i] ?? '';
357
- const absoluteLineIdx = startLine + i;
358
- const isFirstLine = absoluteLineIdx === 0;
359
- const isCursorLine = i === adjustedCursorLine;
360
- // Background
361
- this.write(ESC.BG_DARK);
362
- // Prompt prefix
363
- this.write(ESC.DIM);
364
- this.write(isFirstLine ? this.config.promptChar : this.config.continuationChar);
365
- this.write(ESC.RESET);
366
- this.write(ESC.BG_DARK);
367
- if (isCursorLine) {
368
- const col = Math.min(cursorCol, line.length);
369
- const before = line.slice(0, col);
370
- const at = col < line.length ? line[col] : ' ';
371
- const after = col < line.length ? line.slice(col + 1) : '';
372
- this.write(before);
373
- this.write(ESC.REVERSE + ESC.BOLD);
374
- this.write(at);
375
- this.write(ESC.RESET + ESC.BG_DARK);
376
- this.write(after);
377
- finalRow = currentRow;
378
- finalCol = this.config.promptChar.length + col + 1;
379
- }
380
- else {
381
- this.write(line);
382
- }
383
- // Pad to edge
384
- const lineLen = this.config.promptChar.length + line.length + (isCursorLine && cursorCol >= line.length ? 1 : 0);
385
- const padding = Math.max(0, cols - lineLen - 1);
386
- if (padding > 0)
387
- this.write(' '.repeat(padding));
388
- this.write(ESC.RESET);
389
- currentRow++;
390
- }
391
- // Bottom divider
392
- this.write(ESC.TO(currentRow, 1));
393
- this.write(divider);
394
- currentRow++;
395
- // Mode controls line
396
- this.write(ESC.TO(currentRow, 1));
397
- this.write(this.buildModeControls(cols));
398
- // Restore cursor position during streaming, or show cursor in normal mode
399
- if (isStreaming) {
400
- this.write(ESC.RESTORE);
401
- }
402
- else {
403
- // Position cursor in input area
404
- this.write(ESC.TO(finalRow, Math.min(finalCol, cols)));
405
- this.write(ESC.SHOW);
406
- }
407
- // Update reserved lines for scroll region calculations
408
- this.updateReservedLines(totalHeight);
409
- }
410
- /**
411
- * Render input area during streaming (alias for unified method)
412
- */
413
- renderStreamingInputArea() {
414
- this.renderPinnedInputArea();
415
- }
416
- /**
417
- * Render bottom input area in the reserved scroll region space.
418
- * Uses cursor save/restore to update bottom without affecting content flow.
419
- */
420
- renderBottomInputArea() {
421
- const { rows, cols } = this.getSize();
422
- const maxWidth = Math.max(8, cols - 4);
423
- const divider = renderDivider(cols - 2);
424
- const { dim: DIM, reset: R, green: GREEN } = UI_COLORS;
425
- // Wrap buffer into display lines
426
- const { lines, cursorLine, cursorCol } = this.wrapBuffer(maxWidth);
427
- const displayLines = Math.min(lines.length, 1); // Just show first line during streaming
428
- const visibleLines = lines.slice(0, displayLines);
429
- // Calculate total height for bottom area
430
- const hasModelInfo = !!this.modelInfo;
431
- const totalHeight = 4 + displayLines + (hasModelInfo ? 1 : 0);
432
- const startRow = Math.max(1, rows - totalHeight + 1);
433
- // Save cursor, hide it
434
- this.write(ESC.SAVE);
435
- this.write(ESC.HIDE);
436
- let currentRow = startRow;
437
- // Clear the bottom reserved area
438
- for (let r = startRow; r <= rows; r++) {
439
- this.write(ESC.TO(r, 1));
440
- this.write(ESC.CLEAR_LINE);
441
- }
442
- // Status bar (streaming timer)
443
- this.write(ESC.TO(currentRow, 1));
444
- this.write(this.buildStreamingStatusBar(cols));
445
- currentRow++;
446
- // Model info line (if set)
447
- if (hasModelInfo) {
448
- this.write(ESC.TO(currentRow, 1));
449
- let modelLine = `${DIM}${this.modelInfo}${R}`;
450
- if (this.contextUsage !== null) {
451
- const rem = Math.max(0, 100 - this.contextUsage);
452
- if (rem < 10)
453
- modelLine += ` ${UI_COLORS.red}⚠ ctx: ${rem}%${R}`;
454
- else if (rem < 25)
455
- modelLine += ` ${UI_COLORS.yellow}! ctx: ${rem}%${R}`;
456
- else
457
- modelLine += ` ${DIM}· ctx: ${rem}%${R}`;
458
- }
459
- this.write(modelLine);
460
- currentRow++;
461
- }
462
- // Top divider
463
- this.write(ESC.TO(currentRow, 1));
464
- this.write(divider);
465
- currentRow++;
466
- // Input lines with background styling
467
- for (let i = 0; i < visibleLines.length; i++) {
468
- this.write(ESC.TO(currentRow, 1));
469
- const line = visibleLines[i] ?? '';
470
- const isFirstLine = i === 0;
471
- this.write(ESC.BG_DARK);
472
- this.write(ESC.DIM);
473
- this.write(isFirstLine ? this.config.promptChar : this.config.continuationChar);
474
- this.write(ESC.RESET);
475
- this.write(ESC.BG_DARK);
476
- this.write(line);
477
- // Pad to edge
478
- const lineLen = this.config.promptChar.length + line.length;
479
- const padding = Math.max(0, cols - lineLen - 1);
480
- if (padding > 0)
481
- this.write(' '.repeat(padding));
482
- this.write(ESC.RESET);
483
- currentRow++;
484
- }
485
- // Bottom divider
486
- this.write(ESC.TO(currentRow, 1));
487
- this.write(divider);
488
- currentRow++;
489
- // Mode controls
490
- this.write(ESC.TO(currentRow, 1));
491
- this.write(this.buildModeControls(cols));
492
- // Restore cursor position (back to content area)
493
- this.write(ESC.RESTORE);
494
- this.write(ESC.SHOW);
495
- }
496
- /**
497
- * Enable or disable flow mode.
498
- * In flow mode, the input renders immediately after content (wherever cursor is).
499
- * When disabled, input renders at the absolute bottom of terminal.
500
- */
501
- setFlowMode(enabled) {
502
- if (this.flowMode === enabled)
503
- return;
504
- this.flowMode = enabled;
505
- this.renderDirty = true;
506
- this.scheduleRender();
507
- }
508
- /**
509
- * Check if flow mode is enabled.
510
- */
511
- isFlowMode() {
512
- return this.flowMode;
513
- }
514
- /**
515
- * Set the row where content ends (for idle mode positioning).
516
- * Input area will render starting from this row + 1.
517
- */
518
- setContentEndRow(row) {
519
- this.contentEndRow = Math.max(0, row);
520
- this.renderDirty = true;
521
- this.scheduleRender();
522
- }
523
- /**
524
- * Set available slash commands for auto-complete suggestions.
525
- */
526
- setCommands(commands) {
527
- this.commandSuggestions = commands;
528
- this.updateSuggestions();
529
- }
530
- /**
531
- * Update filtered suggestions based on current input.
532
- */
533
- updateSuggestions() {
534
- const input = this.buffer.trim();
535
- // Only show suggestions when input starts with "/"
536
- if (!input.startsWith('/')) {
537
- this.showSuggestions = false;
538
- this.filteredSuggestions = [];
539
- this.selectedSuggestionIndex = 0;
540
- return;
541
- }
542
- const query = input.toLowerCase();
543
- this.filteredSuggestions = this.commandSuggestions.filter(cmd => cmd.command.toLowerCase().startsWith(query) ||
544
- cmd.command.toLowerCase().includes(query.slice(1)));
545
- // Show suggestions if we have matches
546
- this.showSuggestions = this.filteredSuggestions.length > 0;
547
- // Keep selection in bounds
548
- if (this.selectedSuggestionIndex >= this.filteredSuggestions.length) {
549
- this.selectedSuggestionIndex = Math.max(0, this.filteredSuggestions.length - 1);
215
+ // Streaming ended - render the input area
216
+ this.resetStreamingRenderThrottle();
217
+ this.enableScrollRegion();
218
+ this.forceRender();
550
219
  }
551
220
  }
552
- /**
553
- * Select next suggestion (arrow down / tab).
554
- */
555
- selectNextSuggestion() {
556
- if (!this.showSuggestions || this.filteredSuggestions.length === 0)
557
- return;
558
- this.selectedSuggestionIndex = (this.selectedSuggestionIndex + 1) % this.filteredSuggestions.length;
559
- this.renderDirty = true;
560
- this.scheduleRender();
561
- }
562
- /**
563
- * Select previous suggestion (arrow up / shift+tab).
564
- */
565
- selectPrevSuggestion() {
566
- if (!this.showSuggestions || this.filteredSuggestions.length === 0)
567
- return;
568
- this.selectedSuggestionIndex = this.selectedSuggestionIndex === 0
569
- ? this.filteredSuggestions.length - 1
570
- : this.selectedSuggestionIndex - 1;
571
- this.renderDirty = true;
572
- this.scheduleRender();
573
- }
574
- /**
575
- * Accept current suggestion and insert into buffer.
576
- */
577
- acceptSuggestion() {
578
- if (!this.showSuggestions || this.filteredSuggestions.length === 0)
579
- return false;
580
- const selected = this.filteredSuggestions[this.selectedSuggestionIndex];
581
- if (!selected)
582
- return false;
583
- // Replace buffer with selected command
584
- this.buffer = selected.command + ' ';
585
- this.cursor = this.buffer.length;
586
- this.showSuggestions = false;
587
- this.renderDirty = true;
588
- this.scheduleRender();
589
- return true;
590
- }
591
- /**
592
- * Check if suggestions are visible.
593
- */
594
- areSuggestionsVisible() {
595
- return this.showSuggestions && this.filteredSuggestions.length > 0;
596
- }
597
- /**
598
- * Update token count for metrics display
599
- */
600
- setTokensUsed(tokens) {
601
- this.tokensUsed = tokens;
602
- }
603
- /**
604
- * Toggle thinking/reasoning mode
605
- */
606
- toggleThinking() {
607
- this.thinkingEnabled = !this.thinkingEnabled;
608
- this.emit('thinkingToggle', this.thinkingEnabled);
609
- this.scheduleRender();
610
- }
611
- /**
612
- * Get thinking enabled state
613
- */
614
- isThinkingEnabled() {
615
- return this.thinkingEnabled;
616
- }
617
221
  /**
618
222
  * Keep the top N rows pinned outside the scroll region (used for the launch banner).
619
223
  */
620
224
  setPinnedHeaderLines(count) {
621
- // Set pinned header rows (banner area that scroll region excludes)
622
- if (this.pinnedTopRows !== count) {
623
- this.pinnedTopRows = count;
225
+ // No pinned header rows anymore; keep everything in the scroll region.
226
+ if (this.pinnedTopRows !== 0) {
227
+ this.pinnedTopRows = 0;
624
228
  if (this.scrollRegionActive) {
625
229
  this.applyScrollRegion();
626
230
  }
627
231
  }
628
232
  }
629
- /**
630
- * Anchor prompt rendering near a specific row (inline layout). Pass null to
631
- * restore the default bottom-aligned layout.
632
- */
633
- setInlineAnchor(row) {
634
- if (row === null || row === undefined) {
635
- this.inlineAnchorRow = null;
636
- this.inlineLayout = false;
637
- this.renderDirty = true;
638
- this.render();
639
- return;
640
- }
641
- const { rows } = this.getSize();
642
- const clamped = Math.max(1, Math.min(Math.floor(row), rows));
643
- this.inlineAnchorRow = clamped;
644
- this.inlineLayout = true;
645
- this.renderDirty = true;
646
- this.render();
647
- }
648
- /**
649
- * Provide a dynamic anchor callback. When set, the prompt will follow the
650
- * output by re-evaluating the anchor before each render.
651
- */
652
- setInlineAnchorProvider(provider) {
653
- this.anchorProvider = provider;
654
- if (!provider) {
655
- this.inlineLayout = false;
656
- this.inlineAnchorRow = null;
657
- this.renderDirty = true;
658
- this.render();
659
- return;
660
- }
661
- this.inlineLayout = true;
662
- this.renderDirty = true;
663
- this.render();
664
- }
665
233
  /**
666
234
  * Get current mode
667
235
  */
@@ -771,6 +339,37 @@ export class TerminalInput extends EventEmitter {
771
339
  this.streamingLabel = next;
772
340
  this.scheduleRender();
773
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
+ }
774
373
  /**
775
374
  * Keep mode toggles (verification/auto-continue) visible in the control bar.
776
375
  * Hotkey labels remain stable so the bar looks the same before/during streaming.
@@ -780,26 +379,22 @@ export class TerminalInput extends EventEmitter {
780
379
  const nextAutoContinue = !!options.autoContinueEnabled;
781
380
  const nextVerifyHotkey = options.verificationHotkey ?? this.verificationHotkey;
782
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);
783
384
  if (this.verificationEnabled === nextVerification &&
784
385
  this.autoContinueEnabled === nextAutoContinue &&
785
386
  this.verificationHotkey === nextVerifyHotkey &&
786
- this.autoContinueHotkey === nextAutoHotkey) {
387
+ this.autoContinueHotkey === nextAutoHotkey &&
388
+ this.thinkingHotkey === nextThinkingHotkey &&
389
+ this.thinkingModeLabel === nextThinkingLabel) {
787
390
  return;
788
391
  }
789
392
  this.verificationEnabled = nextVerification;
790
393
  this.autoContinueEnabled = nextAutoContinue;
791
394
  this.verificationHotkey = nextVerifyHotkey;
792
395
  this.autoContinueHotkey = nextAutoHotkey;
793
- this.scheduleRender();
794
- }
795
- /**
796
- * Set the model info string (e.g., "OpenAI · gpt-4")
797
- * This is displayed persistently above the input area.
798
- */
799
- setModelInfo(info) {
800
- if (this.modelInfo === info)
801
- return;
802
- this.modelInfo = info;
396
+ this.thinkingHotkey = nextThinkingHotkey;
397
+ this.thinkingModeLabel = nextThinkingLabel;
803
398
  this.scheduleRender();
804
399
  }
805
400
  /**
@@ -811,302 +406,391 @@ export class TerminalInput extends EventEmitter {
811
406
  this.streamingLabel = null;
812
407
  this.scheduleRender();
813
408
  }
409
+ /**
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
+ }
814
422
  /**
815
423
  * Render the input area - Claude Code style with mode controls
816
424
  *
817
- * During streaming: Uses renderBottomInputArea() with scroll regions
818
- * After streaming: Renders the full input area inline
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.
819
428
  */
820
429
  render() {
821
430
  if (!this.canRender())
822
431
  return;
823
432
  if (this.isRendering)
824
433
  return;
825
- // During streaming, use the bottom input area renderer (with scroll regions)
826
- if (this.mode === 'streaming') {
827
- this.renderBottomInputArea();
828
- 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
+ }
829
445
  }
830
446
  const shouldSkip = !this.renderDirty &&
831
447
  this.buffer === this.lastRenderContent &&
832
448
  this.cursor === this.lastRenderCursor;
833
449
  this.renderDirty = false;
834
- // Skip if nothing changed (unless explicitly forced)
450
+ // Skip if nothing changed and no explicit refresh requested
835
451
  if (shouldSkip) {
836
452
  return;
837
453
  }
838
- // If write lock is held, defer render
454
+ // If write lock is held, defer render to avoid race conditions
839
455
  if (writeLock.isLocked()) {
840
456
  writeLock.safeWrite(() => this.render());
841
457
  return;
842
458
  }
843
- this.isRendering = true;
844
- writeLock.lock('terminalInput.render');
845
- try {
846
- // Render input area at bottom (outside scroll region)
847
- this.renderBottomPinned();
848
- }
849
- finally {
850
- writeLock.unlock();
851
- this.isRendering = false;
852
- }
853
- }
854
- /**
855
- * Render in flow mode - delegates to bottom-pinned for stability.
856
- *
857
- * Flow mode attempted inline rendering but caused duplicate renders
858
- * due to unreliable cursor position tracking. Bottom-pinned is reliable.
859
- */
860
- renderFlowMode() {
861
- // Use stable bottom-pinned approach
862
- this.renderBottomPinned();
863
- }
864
- /**
865
- * Render in bottom-pinned mode - Claude Code style with suggestions
866
- *
867
- * Works for both normal and streaming modes:
868
- * - During streaming: saves/restores cursor position
869
- * - Status bar shows streaming info or "Type a message"
870
- *
871
- * Layout when suggestions visible:
872
- * - Top divider
873
- * - Input line(s)
874
- * - Bottom divider
875
- * - Suggestions (command list)
876
- *
877
- * Layout when suggestions hidden:
878
- * - Status bar (Ready/Streaming)
879
- * - Top divider
880
- * - Input line(s)
881
- * - Bottom divider
882
- * - Mode controls
883
- */
884
- renderBottomPinned() {
885
- const { rows, cols } = this.getSize();
886
- const maxWidth = Math.max(8, cols - 4);
887
- const isStreaming = this.mode === 'streaming';
888
- // Use unified pinned input area (works for both streaming and normal)
889
- // Only use complex rendering when suggestions are visible
890
- const hasSuggestions = !isStreaming && this.showSuggestions && this.filteredSuggestions.length > 0;
891
- if (!hasSuggestions) {
892
- this.renderPinnedInputArea();
893
- return;
894
- }
895
- // Wrap buffer into display lines
896
- const { lines, cursorLine, cursorCol } = this.wrapBuffer(maxWidth);
897
- const availableForContent = Math.max(1, rows - 3);
898
- const maxVisible = Math.max(1, Math.min(this.config.maxLines, availableForContent));
899
- const displayLines = Math.min(lines.length, maxVisible);
900
- // Calculate display window (keep cursor visible)
901
- let startLine = 0;
902
- if (lines.length > displayLines) {
903
- startLine = Math.max(0, cursorLine - displayLines + 1);
904
- startLine = Math.min(startLine, lines.length - displayLines);
905
- }
906
- const visibleLines = lines.slice(startLine, startLine + displayLines);
907
- const adjustedCursorLine = cursorLine - startLine;
908
- // Calculate suggestion display (not during streaming)
909
- const suggestionsToShow = (!isStreaming && this.showSuggestions)
910
- ? this.filteredSuggestions.slice(0, this.maxVisibleSuggestions)
911
- : [];
912
- const suggestionLines = suggestionsToShow.length;
913
- this.write(ESC.HIDE);
914
- this.write(ESC.RESET);
915
- const divider = renderDivider(cols - 2);
916
- // Calculate positions from absolute bottom
917
- let currentRow;
918
- if (suggestionLines > 0) {
919
- // With suggestions: input area + dividers + suggestions
920
- // Layout: [topDiv] [input] [bottomDiv] [suggestions...]
921
- const totalHeight = 2 + visibleLines.length + suggestionLines; // 2 dividers
922
- currentRow = Math.max(1, rows - totalHeight + 1);
923
- this.updateReservedLines(totalHeight);
924
- // Clear from current position to end of screen to remove any "ghost" content
925
- this.write(ESC.TO(currentRow, 1));
926
- this.write(ESC.CLEAR_TO_END);
927
- // 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
928
496
  this.write(ESC.TO(currentRow, 1));
497
+ this.write(ESC.CLEAR_LINE);
498
+ const divider = renderDivider(cols - 2);
929
499
  this.write(divider);
930
- currentRow++;
931
- // Input lines
500
+ currentRow += 1;
501
+ // Render input lines
932
502
  let finalRow = currentRow;
933
503
  let finalCol = 3;
934
504
  for (let i = 0; i < visibleLines.length; i++) {
935
- this.write(ESC.TO(currentRow, 1));
505
+ const rowNum = currentRow + i;
506
+ this.write(ESC.TO(rowNum, 1));
507
+ this.write(ESC.CLEAR_LINE);
936
508
  const line = visibleLines[i] ?? '';
937
509
  const absoluteLineIdx = startLine + i;
938
510
  const isFirstLine = absoluteLineIdx === 0;
939
511
  const isCursorLine = i === adjustedCursorLine;
512
+ // Background
513
+ this.write(ESC.BG_DARK);
514
+ // Prompt prefix
515
+ this.write(ESC.DIM);
940
516
  this.write(isFirstLine ? this.config.promptChar : this.config.continuationChar);
517
+ this.write(ESC.RESET);
518
+ this.write(ESC.BG_DARK);
941
519
  if (isCursorLine) {
520
+ // Render with block cursor
942
521
  const col = Math.min(cursorCol, line.length);
943
- this.write(line.slice(0, col));
944
- this.write(ESC.REVERSE);
945
- this.write(col < line.length ? line[col] : ' ');
946
- this.write(ESC.RESET);
947
- this.write(line.slice(col + 1));
948
- 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;
949
531
  finalCol = this.config.promptChar.length + col + 1;
950
532
  }
951
533
  else {
952
534
  this.write(line);
953
535
  }
954
- 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);
955
542
  }
956
- // Bottom divider
957
- this.write(ESC.TO(currentRow, 1));
958
- this.write(divider);
959
- currentRow++;
960
- // Suggestions (Claude Code style)
961
- for (let i = 0; i < suggestionsToShow.length; i++) {
962
- this.write(ESC.TO(currentRow, 1));
963
- const suggestion = suggestionsToShow[i];
964
- const isSelected = i === this.selectedSuggestionIndex;
965
- // Indent and highlight selected
966
- this.write(' ');
967
- if (isSelected) {
968
- this.write(ESC.REVERSE);
969
- this.write(ESC.BOLD);
970
- }
971
- this.write(suggestion.command);
972
- if (isSelected) {
973
- this.write(ESC.RESET);
974
- }
975
- // Description (dimmed)
976
- const descSpace = cols - suggestion.command.length - 8;
977
- if (descSpace > 10 && suggestion.description) {
978
- const desc = suggestion.description.slice(0, descSpace);
979
- this.write(ESC.RESET);
980
- this.write(ESC.DIM);
981
- this.write(' ');
982
- this.write(desc);
983
- this.write(ESC.RESET);
984
- }
985
- currentRow++;
986
- }
987
- // 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
988
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;
989
569
  }
990
- this.write(ESC.SHOW);
991
- // Update state
992
- this.lastRenderContent = this.buffer;
993
- this.lastRenderCursor = this.cursor;
994
570
  }
995
571
  /**
996
- * 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.
997
574
  */
998
- buildStreamingStatusBar(cols) {
999
- const { green: GREEN, cyan: CYAN, dim: DIM, reset: R } = UI_COLORS;
1000
- // Streaming status with elapsed time
1001
- let elapsed = '0s';
1002
- if (this.streamingStartTime) {
1003
- const secs = Math.floor((Date.now() - this.streamingStartTime) / 1000);
1004
- const mins = Math.floor(secs / 60);
1005
- elapsed = mins > 0 ? `${mins}m ${secs % 60}s` : `${secs}s`;
1006
- }
1007
- let status = `${GREEN}● Streaming${R} ${elapsed}`;
1008
- // 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
+ }
1009
632
  if (this.queue.length > 0) {
1010
- status += ` ${DIM}·${R} ${CYAN}${this.queue.length} queued${R}`;
633
+ usageParts.push({ text: `queued ${this.queue.length}`, tone: 'info' });
634
+ }
635
+ if (usageParts.length) {
636
+ lines.push(renderStatusLine(usageParts, width));
1011
637
  }
1012
- // Hint for typing
1013
- status += ` ${DIM}· type to queue message${R}`;
1014
- return status;
638
+ return lines;
1015
639
  }
1016
640
  /**
1017
- * Build status bar showing streaming/ready status and key info.
1018
- * 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.
1019
642
  */
1020
- buildStatusBar(cols) {
1021
- const maxWidth = cols - 2;
1022
- const parts = [];
1023
- // Streaming status with elapsed time (left side)
1024
- if (this.mode === 'streaming') {
1025
- let statusText = ' Streaming';
1026
- if (this.streamingStartTime) {
1027
- const elapsed = Math.floor((Date.now() - this.streamingStartTime) / 1000);
1028
- const mins = Math.floor(elapsed / 60);
1029
- const secs = elapsed % 60;
1030
- statusText += mins > 0 ? ` ${mins}m ${secs}s` : ` ${secs}s`;
1031
- }
1032
- parts.push(`\x1b[32m${statusText}\x1b[0m`); // Green
1033
- }
1034
- // Queue indicator during streaming
1035
- if (this.mode === 'streaming' && this.queue.length > 0) {
1036
- parts.push(`\x1b[36mqueued: ${this.queue.length}\x1b[0m`); // Cyan
643
+ clearReservedArea(startRow, reservedLines, cols) {
644
+ const width = Math.max(1, cols);
645
+ for (let i = 0; i < reservedLines; i++) {
646
+ const row = startRow + i;
647
+ this.write(ESC.TO(row, 1));
648
+ this.write(' '.repeat(width));
1037
649
  }
1038
- // Paste indicator
1039
- if (this.pastePlaceholders.length > 0) {
1040
- const totalLines = this.pastePlaceholders.reduce((sum, p) => sum + p.lineCount, 0);
1041
- 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' });
1042
661
  }
1043
- // Override/warning status
1044
662
  if (this.overrideStatusMessage) {
1045
- parts.push(`\x1b[33m⚠ ${this.overrideStatusMessage}\x1b[0m`); // Yellow
1046
- }
1047
- // If idle with empty buffer, show quick shortcuts
1048
- if (this.mode !== 'streaming' && this.buffer.length === 0 && parts.length === 0) {
1049
- 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' });
1050
680
  }
1051
- // Multi-line indicator
1052
681
  if (this.buffer.includes('\n')) {
1053
- 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' });
1054
684
  }
1055
- if (parts.length === 0) {
1056
- 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
+ });
1057
691
  }
1058
- const joined = parts.join(`${ESC.DIM} · ${ESC.RESET}`);
1059
- 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;
1060
783
  }
1061
784
  /**
1062
- * Build mode controls line showing toggles and context info.
1063
- * This is the BOTTOM line below the input area - Claude Code style layout with erosolar features.
1064
- *
1065
- * 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.
1066
787
  */
1067
- buildModeControls(cols) {
1068
- const maxWidth = cols - 2;
1069
- // Use schema-defined colors for consistency
1070
- const { green: GREEN, yellow: YELLOW, cyan: CYAN, magenta: MAGENTA, red: RED, dim: DIM, reset: R } = UI_COLORS;
1071
- // Mode toggles with colors (following ModeControlsSchema)
1072
- const toggles = [];
1073
- // Edit mode (green=auto, yellow=ask) - per schema.editMode
1074
- if (this.editMode === 'display-edits') {
1075
- toggles.push(`${GREEN}⏵⏵ auto-edit${R}`);
1076
- }
1077
- else {
1078
- toggles.push(`${YELLOW}⏸⏸ ask-first${R}`);
1079
- }
1080
- // Thinking mode (cyan when on) - per schema.thinkingMode
1081
- toggles.push(this.thinkingEnabled ? `${CYAN}💭 think${R}` : `${DIM}○ think${R}`);
1082
- // Verification (green when on) - per schema.verificationMode
1083
- toggles.push(this.verificationEnabled ? `${GREEN}✓ verify${R}` : `${DIM}○ verify${R}`);
1084
- // Auto-continue (magenta when on) - per schema.autoContinueMode
1085
- toggles.push(this.autoContinueEnabled ? `${MAGENTA}↻ auto${R}` : `${DIM}○ auto${R}`);
1086
- const leftPart = toggles.join(`${DIM} · ${R}`) + `${DIM} (⇧⇥)${R}`;
1087
- // Context usage with color - per schema.contextUsage thresholds
1088
- let rightPart = '';
1089
- if (this.contextUsage !== null) {
1090
- const rem = Math.max(0, 100 - this.contextUsage);
1091
- // Thresholds: critical < 10%, warning < 25%
1092
- if (rem < 10)
1093
- rightPart = `${RED}⚠ ctx: ${rem}%${R}`;
1094
- else if (rem < 25)
1095
- rightPart = `${YELLOW}! ctx: ${rem}%${R}`;
1096
- else
1097
- rightPart = `${DIM}ctx: ${rem}%${R}`;
1098
- }
1099
- // Calculate visible lengths (strip ANSI)
1100
- const strip = (s) => s.replace(/\x1b\[[0-9;]*m/g, '');
1101
- const leftLen = strip(leftPart).length;
1102
- const rightLen = strip(rightPart).length;
1103
- if (leftLen + rightLen < maxWidth - 4) {
1104
- return `${leftPart}${' '.repeat(maxWidth - leftLen - rightLen)}${rightPart}`;
1105
- }
1106
- if (rightLen > 0 && leftLen + 8 < maxWidth) {
1107
- return `${leftPart} ${rightPart}`;
1108
- }
1109
- 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
+ };
1110
794
  }
1111
795
  /**
1112
796
  * Force a re-render
@@ -1129,17 +813,19 @@ export class TerminalInput extends EventEmitter {
1129
813
  handleResize() {
1130
814
  this.lastRenderContent = '';
1131
815
  this.lastRenderCursor = -1;
816
+ this.resetStreamingRenderThrottle();
1132
817
  // Re-clamp pinned header rows to the new terminal height
1133
818
  this.setPinnedHeaderLines(this.pinnedTopRows);
819
+ if (this.scrollRegionActive) {
820
+ this.disableScrollRegion();
821
+ this.enableScrollRegion();
822
+ }
1134
823
  this.scheduleRender();
1135
824
  }
1136
825
  /**
1137
826
  * Register with display's output interceptor to position cursor correctly.
1138
827
  * When scroll region is active, output needs to go to the scroll region,
1139
828
  * not the protected bottom area where the input is rendered.
1140
- *
1141
- * NOTE: With scroll region properly set, content naturally stays within
1142
- * the region boundaries - no cursor manipulation needed per-write.
1143
829
  */
1144
830
  registerOutputInterceptor(display) {
1145
831
  if (this.outputInterceptorCleanup) {
@@ -1147,25 +833,49 @@ export class TerminalInput extends EventEmitter {
1147
833
  }
1148
834
  this.outputInterceptorCleanup = display.registerOutputInterceptor({
1149
835
  beforeWrite: () => {
1150
- // Scroll region handles content containment automatically
1151
- // No per-write cursor manipulation needed
836
+ // Move cursor to next content row in scroll region.
837
+ // Content flows from where banner ended, pushing banner up as it fills.
838
+ if (this.scrollRegionActive) {
839
+ const { rows } = this.getSize();
840
+ const scrollBottom = Math.max(this.pinnedTopRows + 1, rows - this.reservedLines);
841
+ // Use tracked position, clamped to scroll region bounds
842
+ const targetRow = Math.min(this.nextContentRow, scrollBottom);
843
+ this.write(ESC.SAVE);
844
+ this.write(ESC.TO(targetRow, 1));
845
+ }
1152
846
  },
1153
847
  afterWrite: () => {
1154
- // No cursor manipulation needed
848
+ // Advance content row and restore cursor.
849
+ if (this.scrollRegionActive) {
850
+ const { rows } = this.getSize();
851
+ const scrollBottom = Math.max(this.pinnedTopRows + 1, rows - this.reservedLines);
852
+ // Advance row for next content (clamp at scrollBottom, terminal handles scrolling)
853
+ this.nextContentRow = Math.min(this.nextContentRow + 1, scrollBottom);
854
+ this.write(ESC.RESTORE);
855
+ }
1155
856
  },
1156
857
  });
1157
858
  }
859
+ /**
860
+ * Advance content cursor by specified lines (call after writing known number of lines).
861
+ */
862
+ advanceContentRow(lines = 1) {
863
+ const { rows } = this.getSize();
864
+ const scrollBottom = Math.max(this.pinnedTopRows + 1, rows - this.reservedLines);
865
+ this.nextContentRow = Math.min(this.nextContentRow + lines, scrollBottom);
866
+ }
867
+ /**
868
+ * Reset content cursor to start of scroll region.
869
+ */
870
+ resetContentRow() {
871
+ this.nextContentRow = Math.max(1, this.pinnedTopRows + 1);
872
+ }
1158
873
  /**
1159
874
  * Dispose and clean up
1160
875
  */
1161
876
  dispose() {
1162
877
  if (this.disposed)
1163
878
  return;
1164
- // Clean up streaming render timer
1165
- if (this.streamingRenderTimer) {
1166
- clearInterval(this.streamingRenderTimer);
1167
- this.streamingRenderTimer = null;
1168
- }
1169
879
  // Clean up output interceptor
1170
880
  if (this.outputInterceptorCleanup) {
1171
881
  this.outputInterceptorCleanup();
@@ -1173,6 +883,7 @@ export class TerminalInput extends EventEmitter {
1173
883
  }
1174
884
  this.disposed = true;
1175
885
  this.enabled = false;
886
+ this.resetStreamingRenderThrottle();
1176
887
  this.disableScrollRegion();
1177
888
  this.disableBracketedPaste();
1178
889
  this.buffer = '';
@@ -1278,22 +989,7 @@ export class TerminalInput extends EventEmitter {
1278
989
  this.toggleEditMode();
1279
990
  return true;
1280
991
  }
1281
- // Tab: toggle paste expansion if in placeholder, otherwise toggle thinking
1282
- if (this.findPlaceholderAt(this.cursor)) {
1283
- this.togglePasteExpansion();
1284
- }
1285
- else {
1286
- this.toggleThinking();
1287
- }
1288
- return true;
1289
- case 'escape':
1290
- // Esc: interrupt if streaming, otherwise clear buffer
1291
- if (this.mode === 'streaming') {
1292
- this.emit('interrupt');
1293
- }
1294
- else if (this.buffer.length > 0) {
1295
- this.clear();
1296
- }
992
+ this.insertText(' ');
1297
993
  return true;
1298
994
  }
1299
995
  return false;
@@ -1311,7 +1007,6 @@ export class TerminalInput extends EventEmitter {
1311
1007
  this.insertPlainText(chunk, insertPos);
1312
1008
  this.cursor = insertPos + chunk.length;
1313
1009
  this.emit('change', this.buffer);
1314
- this.updateSuggestions();
1315
1010
  this.scheduleRender();
1316
1011
  }
1317
1012
  insertNewline() {
@@ -1336,7 +1031,6 @@ export class TerminalInput extends EventEmitter {
1336
1031
  this.cursor = Math.max(0, this.cursor - 1);
1337
1032
  }
1338
1033
  this.emit('change', this.buffer);
1339
- this.updateSuggestions();
1340
1034
  this.scheduleRender();
1341
1035
  }
1342
1036
  deleteForward() {
@@ -1586,7 +1280,9 @@ export class TerminalInput extends EventEmitter {
1586
1280
  if (available <= 0)
1587
1281
  return;
1588
1282
  const chunk = clean.slice(0, available);
1589
- if (isMultilinePaste(chunk)) {
1283
+ const isMultiline = isMultilinePaste(chunk);
1284
+ const isShortMultiline = isMultiline && this.shouldInlineMultiline(chunk);
1285
+ if (isMultiline && !isShortMultiline) {
1590
1286
  this.insertPastePlaceholder(chunk);
1591
1287
  }
1592
1288
  else {
@@ -1606,6 +1302,7 @@ export class TerminalInput extends EventEmitter {
1606
1302
  return;
1607
1303
  this.applyScrollRegion();
1608
1304
  this.scrollRegionActive = true;
1305
+ this.forceRender();
1609
1306
  }
1610
1307
  disableScrollRegion() {
1611
1308
  if (!this.scrollRegionActive)
@@ -1756,17 +1453,19 @@ export class TerminalInput extends EventEmitter {
1756
1453
  this.shiftPlaceholders(position, text.length);
1757
1454
  this.buffer = this.buffer.slice(0, position) + text + this.buffer.slice(position);
1758
1455
  }
1456
+ shouldInlineMultiline(content) {
1457
+ const lines = content.split('\n').length;
1458
+ const maxInlineLines = 4;
1459
+ const maxInlineChars = 240;
1460
+ return lines <= maxInlineLines && content.length <= maxInlineChars;
1461
+ }
1759
1462
  findPlaceholderAt(position) {
1760
1463
  return this.pastePlaceholders.find((ph) => position >= ph.start && position < ph.end) ?? null;
1761
1464
  }
1762
- buildPlaceholder(summary) {
1465
+ buildPlaceholder(lineCount) {
1763
1466
  const id = ++this.pasteCounter;
1764
- const lang = summary.language ? ` ${summary.language.toUpperCase()}` : '';
1765
- // Show first line preview (truncated)
1766
- const preview = summary.preview.length > 30
1767
- ? `${summary.preview.slice(0, 30)}...`
1768
- : summary.preview;
1769
- const placeholder = `[📋 #${id}${lang} ${summary.lineCount}L] "${preview}"`;
1467
+ const plural = lineCount === 1 ? '' : 's';
1468
+ const placeholder = `[Pasted text #${id} +${lineCount} line${plural}]`;
1770
1469
  return { id, placeholder };
1771
1470
  }
1772
1471
  insertPastePlaceholder(content) {
@@ -1774,67 +1473,21 @@ export class TerminalInput extends EventEmitter {
1774
1473
  if (available <= 0)
1775
1474
  return;
1776
1475
  const cleanContent = content.slice(0, available);
1777
- const summary = generatePasteSummary(cleanContent);
1778
- // For short pastes (< 5 lines), show full content instead of placeholder
1779
- if (summary.lineCount < 5) {
1780
- const placeholder = this.findPlaceholderAt(this.cursor);
1781
- const insertPos = placeholder && this.cursor > placeholder.start ? placeholder.end : this.cursor;
1782
- this.insertPlainText(cleanContent, insertPos);
1783
- this.cursor = insertPos + cleanContent.length;
1784
- return;
1785
- }
1786
- const { id, placeholder } = this.buildPlaceholder(summary);
1476
+ const lineCount = cleanContent.split('\n').length;
1477
+ const { id, placeholder } = this.buildPlaceholder(lineCount);
1787
1478
  const insertPos = this.cursor;
1788
1479
  this.shiftPlaceholders(insertPos, placeholder.length);
1789
1480
  this.pastePlaceholders.push({
1790
1481
  id,
1791
1482
  content: cleanContent,
1792
- lineCount: summary.lineCount,
1483
+ lineCount,
1793
1484
  placeholder,
1794
1485
  start: insertPos,
1795
1486
  end: insertPos + placeholder.length,
1796
- summary,
1797
- expanded: false,
1798
1487
  });
1799
1488
  this.buffer = this.buffer.slice(0, insertPos) + placeholder + this.buffer.slice(insertPos);
1800
1489
  this.cursor = insertPos + placeholder.length;
1801
1490
  }
1802
- /**
1803
- * Toggle expansion of a paste placeholder at the current cursor position.
1804
- * When expanded, shows first 3 and last 2 lines of the content.
1805
- */
1806
- togglePasteExpansion() {
1807
- const placeholder = this.findPlaceholderAt(this.cursor);
1808
- if (!placeholder)
1809
- return false;
1810
- placeholder.expanded = !placeholder.expanded;
1811
- // Update the placeholder text in buffer
1812
- const newPlaceholder = placeholder.expanded
1813
- ? this.buildExpandedPlaceholder(placeholder)
1814
- : this.buildPlaceholder(placeholder.summary).placeholder;
1815
- const lengthDiff = newPlaceholder.length - placeholder.placeholder.length;
1816
- // Update buffer
1817
- this.buffer =
1818
- this.buffer.slice(0, placeholder.start) +
1819
- newPlaceholder +
1820
- this.buffer.slice(placeholder.end);
1821
- // Update placeholder tracking
1822
- placeholder.placeholder = newPlaceholder;
1823
- placeholder.end = placeholder.start + newPlaceholder.length;
1824
- // Shift other placeholders
1825
- this.shiftPlaceholders(placeholder.end, lengthDiff, placeholder.id);
1826
- this.scheduleRender();
1827
- return true;
1828
- }
1829
- buildExpandedPlaceholder(ph) {
1830
- const lines = ph.content.split('\n');
1831
- const firstLines = lines.slice(0, 3).map(l => l.slice(0, 60)).join('\n');
1832
- const lastLines = lines.length > 5
1833
- ? '\n...\n' + lines.slice(-2).map(l => l.slice(0, 60)).join('\n')
1834
- : '';
1835
- const lang = ph.summary.language ? ` ${ph.summary.language.toUpperCase()}` : '';
1836
- return `[📋 #${ph.id}${lang} ${ph.lineCount}L ▼]\n${firstLines}${lastLines}\n[/📋 #${ph.id}]`;
1837
- }
1838
1491
  deletePlaceholder(placeholder) {
1839
1492
  const length = placeholder.end - placeholder.start;
1840
1493
  this.buffer = this.buffer.slice(0, placeholder.start) + this.buffer.slice(placeholder.end);
@@ -1842,7 +1495,11 @@ export class TerminalInput extends EventEmitter {
1842
1495
  this.shiftPlaceholders(placeholder.end, -length, placeholder.id);
1843
1496
  this.cursor = placeholder.start;
1844
1497
  }
1845
- updateContextUsage(value) {
1498
+ updateContextUsage(value, autoCompactThreshold) {
1499
+ if (typeof autoCompactThreshold === 'number' && Number.isFinite(autoCompactThreshold)) {
1500
+ const boundedThreshold = Math.max(1, Math.min(100, Math.round(autoCompactThreshold)));
1501
+ this.contextAutoCompactThreshold = boundedThreshold;
1502
+ }
1846
1503
  if (value === null || !Number.isFinite(value)) {
1847
1504
  this.contextUsage = null;
1848
1505
  }
@@ -1869,6 +1526,22 @@ export class TerminalInput extends EventEmitter {
1869
1526
  const next = this.editMode === 'display-edits' ? 'ask-permission' : 'display-edits';
1870
1527
  this.setEditMode(next);
1871
1528
  }
1529
+ scheduleStreamingRender(delayMs) {
1530
+ if (this.streamingRenderTimer)
1531
+ return;
1532
+ const wait = Math.max(16, delayMs);
1533
+ this.streamingRenderTimer = setTimeout(() => {
1534
+ this.streamingRenderTimer = null;
1535
+ this.render();
1536
+ }, wait);
1537
+ }
1538
+ resetStreamingRenderThrottle() {
1539
+ if (this.streamingRenderTimer) {
1540
+ clearTimeout(this.streamingRenderTimer);
1541
+ this.streamingRenderTimer = null;
1542
+ }
1543
+ this.lastStreamingRender = 0;
1544
+ }
1872
1545
  scheduleRender() {
1873
1546
  if (!this.canRender())
1874
1547
  return;