erosolar-cli 1.7.266 → 1.7.267

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