erosolar-cli 1.7.354 → 1.7.356

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 (328) hide show
  1. package/README.md +148 -24
  2. package/dist/alpha-zero/agentWrapper.d.ts +84 -0
  3. package/dist/alpha-zero/agentWrapper.d.ts.map +1 -0
  4. package/dist/alpha-zero/agentWrapper.js +171 -0
  5. package/dist/alpha-zero/agentWrapper.js.map +1 -0
  6. package/dist/alpha-zero/codeEvaluator.d.ts +25 -0
  7. package/dist/alpha-zero/codeEvaluator.d.ts.map +1 -0
  8. package/dist/alpha-zero/codeEvaluator.js +273 -0
  9. package/dist/alpha-zero/codeEvaluator.js.map +1 -0
  10. package/dist/alpha-zero/competitiveRunner.d.ts +66 -0
  11. package/dist/alpha-zero/competitiveRunner.d.ts.map +1 -0
  12. package/dist/alpha-zero/competitiveRunner.js +224 -0
  13. package/dist/alpha-zero/competitiveRunner.js.map +1 -0
  14. package/dist/alpha-zero/index.d.ts +67 -0
  15. package/dist/alpha-zero/index.d.ts.map +1 -0
  16. package/dist/alpha-zero/index.js +99 -0
  17. package/dist/alpha-zero/index.js.map +1 -0
  18. package/dist/alpha-zero/introspection.d.ts +128 -0
  19. package/dist/alpha-zero/introspection.d.ts.map +1 -0
  20. package/dist/alpha-zero/introspection.js +300 -0
  21. package/dist/alpha-zero/introspection.js.map +1 -0
  22. package/dist/alpha-zero/metricsTracker.d.ts +71 -0
  23. package/dist/alpha-zero/metricsTracker.d.ts.map +1 -0
  24. package/dist/{core → alpha-zero}/metricsTracker.js +5 -2
  25. package/dist/alpha-zero/metricsTracker.js.map +1 -0
  26. package/dist/alpha-zero/security/core.d.ts +125 -0
  27. package/dist/alpha-zero/security/core.d.ts.map +1 -0
  28. package/dist/alpha-zero/security/core.js +271 -0
  29. package/dist/alpha-zero/security/core.js.map +1 -0
  30. package/dist/alpha-zero/security/google.d.ts +125 -0
  31. package/dist/alpha-zero/security/google.d.ts.map +1 -0
  32. package/dist/alpha-zero/security/google.js +311 -0
  33. package/dist/alpha-zero/security/google.js.map +1 -0
  34. package/dist/alpha-zero/security/googleLoader.d.ts +17 -0
  35. package/dist/alpha-zero/security/googleLoader.d.ts.map +1 -0
  36. package/dist/alpha-zero/security/googleLoader.js +41 -0
  37. package/dist/alpha-zero/security/googleLoader.js.map +1 -0
  38. package/dist/alpha-zero/security/index.d.ts +29 -0
  39. package/dist/alpha-zero/security/index.d.ts.map +1 -0
  40. package/dist/alpha-zero/security/index.js +32 -0
  41. package/dist/alpha-zero/security/index.js.map +1 -0
  42. package/dist/alpha-zero/security/simulation.d.ts +124 -0
  43. package/dist/alpha-zero/security/simulation.d.ts.map +1 -0
  44. package/dist/alpha-zero/security/simulation.js +277 -0
  45. package/dist/alpha-zero/security/simulation.js.map +1 -0
  46. package/dist/alpha-zero/selfModification.d.ts +109 -0
  47. package/dist/alpha-zero/selfModification.d.ts.map +1 -0
  48. package/dist/alpha-zero/selfModification.js +233 -0
  49. package/dist/alpha-zero/selfModification.js.map +1 -0
  50. package/dist/alpha-zero/types.d.ts +170 -0
  51. package/dist/alpha-zero/types.d.ts.map +1 -0
  52. package/dist/alpha-zero/types.js +31 -0
  53. package/dist/alpha-zero/types.js.map +1 -0
  54. package/dist/bin/erosolar.js +21 -5
  55. package/dist/bin/erosolar.js.map +1 -1
  56. package/dist/capabilities/agentSpawningCapability.d.ts.map +1 -1
  57. package/dist/capabilities/agentSpawningCapability.js +31 -56
  58. package/dist/capabilities/agentSpawningCapability.js.map +1 -1
  59. package/dist/capabilities/securityTestingCapability.d.ts +13 -0
  60. package/dist/capabilities/securityTestingCapability.d.ts.map +1 -0
  61. package/dist/capabilities/securityTestingCapability.js +25 -0
  62. package/dist/capabilities/securityTestingCapability.js.map +1 -0
  63. package/dist/contracts/agent-schemas.json +15 -0
  64. package/dist/contracts/tools.schema.json +9 -0
  65. package/dist/core/agent.d.ts +2 -2
  66. package/dist/core/agent.d.ts.map +1 -1
  67. package/dist/core/agent.js.map +1 -1
  68. package/dist/core/aiFlowOptimizer.d.ts +26 -0
  69. package/dist/core/aiFlowOptimizer.d.ts.map +1 -0
  70. package/dist/core/aiFlowOptimizer.js +31 -0
  71. package/dist/core/aiFlowOptimizer.js.map +1 -0
  72. package/dist/core/aiOptimizationEngine.d.ts +158 -0
  73. package/dist/core/aiOptimizationEngine.d.ts.map +1 -0
  74. package/dist/core/aiOptimizationEngine.js +428 -0
  75. package/dist/core/aiOptimizationEngine.js.map +1 -0
  76. package/dist/core/aiOptimizationIntegration.d.ts +93 -0
  77. package/dist/core/aiOptimizationIntegration.d.ts.map +1 -0
  78. package/dist/core/aiOptimizationIntegration.js +250 -0
  79. package/dist/core/aiOptimizationIntegration.js.map +1 -0
  80. package/dist/core/customCommands.d.ts +0 -1
  81. package/dist/core/customCommands.d.ts.map +1 -1
  82. package/dist/core/customCommands.js +0 -3
  83. package/dist/core/customCommands.js.map +1 -1
  84. package/dist/core/enhancedErrorRecovery.d.ts +100 -0
  85. package/dist/core/enhancedErrorRecovery.d.ts.map +1 -0
  86. package/dist/core/enhancedErrorRecovery.js +345 -0
  87. package/dist/core/enhancedErrorRecovery.js.map +1 -0
  88. package/dist/core/hooksSystem.d.ts +65 -0
  89. package/dist/core/hooksSystem.d.ts.map +1 -0
  90. package/dist/core/hooksSystem.js +273 -0
  91. package/dist/core/hooksSystem.js.map +1 -0
  92. package/dist/core/memorySystem.d.ts +48 -0
  93. package/dist/core/memorySystem.d.ts.map +1 -0
  94. package/dist/core/memorySystem.js +271 -0
  95. package/dist/core/memorySystem.js.map +1 -0
  96. package/dist/core/sessionStore.d.ts +0 -2
  97. package/dist/core/sessionStore.d.ts.map +1 -1
  98. package/dist/core/sessionStore.js +0 -1
  99. package/dist/core/sessionStore.js.map +1 -1
  100. package/dist/core/toolPreconditions.d.ts.map +1 -1
  101. package/dist/core/toolPreconditions.js +14 -0
  102. package/dist/core/toolPreconditions.js.map +1 -1
  103. package/dist/core/toolRuntime.d.ts +1 -22
  104. package/dist/core/toolRuntime.d.ts.map +1 -1
  105. package/dist/core/toolRuntime.js +5 -0
  106. package/dist/core/toolRuntime.js.map +1 -1
  107. package/dist/core/toolValidation.d.ts.map +1 -1
  108. package/dist/core/toolValidation.js +3 -14
  109. package/dist/core/toolValidation.js.map +1 -1
  110. package/dist/core/unified/errors.d.ts +189 -0
  111. package/dist/core/unified/errors.d.ts.map +1 -0
  112. package/dist/core/unified/errors.js +497 -0
  113. package/dist/core/unified/errors.js.map +1 -0
  114. package/dist/core/unified/index.d.ts +19 -0
  115. package/dist/core/unified/index.d.ts.map +1 -0
  116. package/dist/core/unified/index.js +68 -0
  117. package/dist/core/unified/index.js.map +1 -0
  118. package/dist/core/unified/schema.d.ts +101 -0
  119. package/dist/core/unified/schema.d.ts.map +1 -0
  120. package/dist/core/unified/schema.js +350 -0
  121. package/dist/core/unified/schema.js.map +1 -0
  122. package/dist/core/unified/toolRuntime.d.ts +179 -0
  123. package/dist/core/unified/toolRuntime.d.ts.map +1 -0
  124. package/dist/core/unified/toolRuntime.js +517 -0
  125. package/dist/core/unified/toolRuntime.js.map +1 -0
  126. package/dist/core/unified/tools.d.ts +127 -0
  127. package/dist/core/unified/tools.d.ts.map +1 -0
  128. package/dist/core/unified/tools.js +1333 -0
  129. package/dist/core/unified/tools.js.map +1 -0
  130. package/dist/core/unified/types.d.ts +352 -0
  131. package/dist/core/unified/types.d.ts.map +1 -0
  132. package/dist/core/unified/types.js +12 -0
  133. package/dist/core/unified/types.js.map +1 -0
  134. package/dist/core/unified/version.d.ts +209 -0
  135. package/dist/core/unified/version.d.ts.map +1 -0
  136. package/dist/core/unified/version.js +454 -0
  137. package/dist/core/unified/version.js.map +1 -0
  138. package/dist/core/validationRunner.d.ts +3 -1
  139. package/dist/core/validationRunner.d.ts.map +1 -1
  140. package/dist/core/validationRunner.js.map +1 -1
  141. package/dist/headless/headlessApp.d.ts.map +1 -1
  142. package/dist/headless/headlessApp.js +0 -21
  143. package/dist/headless/headlessApp.js.map +1 -1
  144. package/dist/mcp/sseClient.d.ts.map +1 -1
  145. package/dist/mcp/sseClient.js +18 -9
  146. package/dist/mcp/sseClient.js.map +1 -1
  147. package/dist/plugins/tools/build/buildPlugin.d.ts +6 -0
  148. package/dist/plugins/tools/build/buildPlugin.d.ts.map +1 -1
  149. package/dist/plugins/tools/build/buildPlugin.js +10 -4
  150. package/dist/plugins/tools/build/buildPlugin.js.map +1 -1
  151. package/dist/plugins/tools/nodeDefaults.d.ts.map +1 -1
  152. package/dist/plugins/tools/nodeDefaults.js +2 -0
  153. package/dist/plugins/tools/nodeDefaults.js.map +1 -1
  154. package/dist/plugins/tools/security/securityPlugin.d.ts +3 -0
  155. package/dist/plugins/tools/security/securityPlugin.d.ts.map +1 -0
  156. package/dist/plugins/tools/security/securityPlugin.js +12 -0
  157. package/dist/plugins/tools/security/securityPlugin.js.map +1 -0
  158. package/dist/runtime/agentSession.d.ts +2 -2
  159. package/dist/runtime/agentSession.d.ts.map +1 -1
  160. package/dist/runtime/agentSession.js +2 -2
  161. package/dist/runtime/agentSession.js.map +1 -1
  162. package/dist/security/active-stack-security.d.ts +112 -0
  163. package/dist/security/active-stack-security.d.ts.map +1 -0
  164. package/dist/security/active-stack-security.js +296 -0
  165. package/dist/security/active-stack-security.js.map +1 -0
  166. package/dist/security/advanced-persistence-research.d.ts +92 -0
  167. package/dist/security/advanced-persistence-research.d.ts.map +1 -0
  168. package/dist/security/advanced-persistence-research.js +195 -0
  169. package/dist/security/advanced-persistence-research.js.map +1 -0
  170. package/dist/security/advanced-targeting.d.ts +119 -0
  171. package/dist/security/advanced-targeting.d.ts.map +1 -0
  172. package/dist/security/advanced-targeting.js +233 -0
  173. package/dist/security/advanced-targeting.js.map +1 -0
  174. package/dist/security/assessment/vulnerabilityAssessment.d.ts +104 -0
  175. package/dist/security/assessment/vulnerabilityAssessment.d.ts.map +1 -0
  176. package/dist/security/assessment/vulnerabilityAssessment.js +315 -0
  177. package/dist/security/assessment/vulnerabilityAssessment.js.map +1 -0
  178. package/dist/security/authorization/securityAuthorization.d.ts +88 -0
  179. package/dist/security/authorization/securityAuthorization.d.ts.map +1 -0
  180. package/dist/security/authorization/securityAuthorization.js +172 -0
  181. package/dist/security/authorization/securityAuthorization.js.map +1 -0
  182. package/dist/security/comprehensive-targeting.d.ts +85 -0
  183. package/dist/security/comprehensive-targeting.d.ts.map +1 -0
  184. package/dist/security/comprehensive-targeting.js +438 -0
  185. package/dist/security/comprehensive-targeting.js.map +1 -0
  186. package/dist/security/global-security-integration.d.ts +91 -0
  187. package/dist/security/global-security-integration.d.ts.map +1 -0
  188. package/dist/security/global-security-integration.js +218 -0
  189. package/dist/security/global-security-integration.js.map +1 -0
  190. package/dist/security/index.d.ts +38 -0
  191. package/dist/security/index.d.ts.map +1 -0
  192. package/dist/security/index.js +47 -0
  193. package/dist/security/index.js.map +1 -0
  194. package/dist/security/persistence-analyzer.d.ts +56 -0
  195. package/dist/security/persistence-analyzer.d.ts.map +1 -0
  196. package/dist/security/persistence-analyzer.js +187 -0
  197. package/dist/security/persistence-analyzer.js.map +1 -0
  198. package/dist/security/persistence-cli.d.ts +36 -0
  199. package/dist/security/persistence-cli.d.ts.map +1 -0
  200. package/dist/security/persistence-cli.js +160 -0
  201. package/dist/security/persistence-cli.js.map +1 -0
  202. package/dist/security/persistence-research.d.ts +92 -0
  203. package/dist/security/persistence-research.d.ts.map +1 -0
  204. package/dist/security/persistence-research.js +364 -0
  205. package/dist/security/persistence-research.js.map +1 -0
  206. package/dist/security/research/persistenceResearch.d.ts +97 -0
  207. package/dist/security/research/persistenceResearch.d.ts.map +1 -0
  208. package/dist/security/research/persistenceResearch.js +282 -0
  209. package/dist/security/research/persistenceResearch.js.map +1 -0
  210. package/dist/security/security-integration.d.ts +74 -0
  211. package/dist/security/security-integration.d.ts.map +1 -0
  212. package/dist/security/security-integration.js +137 -0
  213. package/dist/security/security-integration.js.map +1 -0
  214. package/dist/security/security-testing-framework.d.ts +112 -0
  215. package/dist/security/security-testing-framework.d.ts.map +1 -0
  216. package/dist/security/security-testing-framework.js +364 -0
  217. package/dist/security/security-testing-framework.js.map +1 -0
  218. package/dist/security/simulation/attackSimulation.d.ts +93 -0
  219. package/dist/security/simulation/attackSimulation.d.ts.map +1 -0
  220. package/dist/security/simulation/attackSimulation.js +341 -0
  221. package/dist/security/simulation/attackSimulation.js.map +1 -0
  222. package/dist/security/strategic-operations.d.ts +100 -0
  223. package/dist/security/strategic-operations.d.ts.map +1 -0
  224. package/dist/security/strategic-operations.js +276 -0
  225. package/dist/security/strategic-operations.js.map +1 -0
  226. package/dist/security/tool-security-wrapper.d.ts +58 -0
  227. package/dist/security/tool-security-wrapper.d.ts.map +1 -0
  228. package/dist/security/tool-security-wrapper.js +156 -0
  229. package/dist/security/tool-security-wrapper.js.map +1 -0
  230. package/dist/shell/claudeCodeStreamHandler.d.ts +145 -0
  231. package/dist/shell/claudeCodeStreamHandler.d.ts.map +1 -0
  232. package/dist/shell/claudeCodeStreamHandler.js +322 -0
  233. package/dist/shell/claudeCodeStreamHandler.js.map +1 -0
  234. package/dist/shell/inputQueueManager.d.ts +144 -0
  235. package/dist/shell/inputQueueManager.d.ts.map +1 -0
  236. package/dist/shell/inputQueueManager.js +290 -0
  237. package/dist/shell/inputQueueManager.js.map +1 -0
  238. package/dist/shell/interactiveShell.d.ts +7 -43
  239. package/dist/shell/interactiveShell.d.ts.map +1 -1
  240. package/dist/shell/interactiveShell.js +166 -417
  241. package/dist/shell/interactiveShell.js.map +1 -1
  242. package/dist/shell/metricsTracker.d.ts +60 -0
  243. package/dist/shell/metricsTracker.d.ts.map +1 -0
  244. package/dist/shell/metricsTracker.js +119 -0
  245. package/dist/shell/metricsTracker.js.map +1 -0
  246. package/dist/shell/shellApp.d.ts +0 -2
  247. package/dist/shell/shellApp.d.ts.map +1 -1
  248. package/dist/shell/shellApp.js +9 -82
  249. package/dist/shell/shellApp.js.map +1 -1
  250. package/dist/shell/streamingOutputManager.d.ts +115 -0
  251. package/dist/shell/streamingOutputManager.d.ts.map +1 -0
  252. package/dist/shell/streamingOutputManager.js +225 -0
  253. package/dist/shell/streamingOutputManager.js.map +1 -0
  254. package/dist/shell/systemPrompt.d.ts.map +1 -1
  255. package/dist/shell/systemPrompt.js +4 -1
  256. package/dist/shell/systemPrompt.js.map +1 -1
  257. package/dist/shell/terminalInput.d.ts +125 -250
  258. package/dist/shell/terminalInput.d.ts.map +1 -1
  259. package/dist/shell/terminalInput.js +612 -1071
  260. package/dist/shell/terminalInput.js.map +1 -1
  261. package/dist/shell/terminalInputAdapter.d.ts +24 -106
  262. package/dist/shell/terminalInputAdapter.d.ts.map +1 -1
  263. package/dist/shell/terminalInputAdapter.js +30 -139
  264. package/dist/shell/terminalInputAdapter.js.map +1 -1
  265. package/dist/subagents/taskRunner.d.ts +1 -7
  266. package/dist/subagents/taskRunner.d.ts.map +1 -1
  267. package/dist/subagents/taskRunner.js +49 -200
  268. package/dist/subagents/taskRunner.js.map +1 -1
  269. package/dist/tools/securityTools.d.ts +22 -0
  270. package/dist/tools/securityTools.d.ts.map +1 -0
  271. package/dist/tools/securityTools.js +448 -0
  272. package/dist/tools/securityTools.js.map +1 -0
  273. package/dist/ui/ShellUIAdapter.d.ts +1 -7
  274. package/dist/ui/ShellUIAdapter.d.ts.map +1 -1
  275. package/dist/ui/ShellUIAdapter.js +18 -42
  276. package/dist/ui/ShellUIAdapter.js.map +1 -1
  277. package/dist/ui/display.d.ts +45 -24
  278. package/dist/ui/display.d.ts.map +1 -1
  279. package/dist/ui/display.js +274 -148
  280. package/dist/ui/display.js.map +1 -1
  281. package/dist/ui/persistentPrompt.d.ts +50 -0
  282. package/dist/ui/persistentPrompt.d.ts.map +1 -0
  283. package/dist/ui/persistentPrompt.js +92 -0
  284. package/dist/ui/persistentPrompt.js.map +1 -0
  285. package/dist/ui/terminalUISchema.d.ts +195 -0
  286. package/dist/ui/terminalUISchema.d.ts.map +1 -0
  287. package/dist/ui/terminalUISchema.js +113 -0
  288. package/dist/ui/terminalUISchema.js.map +1 -0
  289. package/dist/ui/theme.d.ts.map +1 -1
  290. package/dist/ui/theme.js +8 -6
  291. package/dist/ui/theme.js.map +1 -1
  292. package/dist/ui/toolDisplay.d.ts +158 -0
  293. package/dist/ui/toolDisplay.d.ts.map +1 -1
  294. package/dist/ui/toolDisplay.js +348 -0
  295. package/dist/ui/toolDisplay.js.map +1 -1
  296. package/dist/ui/unified/layout.d.ts +0 -20
  297. package/dist/ui/unified/layout.d.ts.map +1 -1
  298. package/dist/ui/unified/layout.js +216 -105
  299. package/dist/ui/unified/layout.js.map +1 -1
  300. package/package.json +4 -4
  301. package/scripts/deploy-security-capabilities.js +178 -0
  302. package/dist/core/hooks.d.ts +0 -113
  303. package/dist/core/hooks.d.ts.map +0 -1
  304. package/dist/core/hooks.js +0 -267
  305. package/dist/core/hooks.js.map +0 -1
  306. package/dist/core/metricsTracker.d.ts +0 -122
  307. package/dist/core/metricsTracker.d.ts.map +0 -1
  308. package/dist/core/metricsTracker.js.map +0 -1
  309. package/dist/core/securityAssessment.d.ts +0 -91
  310. package/dist/core/securityAssessment.d.ts.map +0 -1
  311. package/dist/core/securityAssessment.js +0 -580
  312. package/dist/core/securityAssessment.js.map +0 -1
  313. package/dist/core/verification.d.ts +0 -137
  314. package/dist/core/verification.d.ts.map +0 -1
  315. package/dist/core/verification.js +0 -323
  316. package/dist/core/verification.js.map +0 -1
  317. package/dist/subagents/agentConfig.d.ts +0 -27
  318. package/dist/subagents/agentConfig.d.ts.map +0 -1
  319. package/dist/subagents/agentConfig.js +0 -89
  320. package/dist/subagents/agentConfig.js.map +0 -1
  321. package/dist/subagents/agentRegistry.d.ts +0 -33
  322. package/dist/subagents/agentRegistry.d.ts.map +0 -1
  323. package/dist/subagents/agentRegistry.js +0 -162
  324. package/dist/subagents/agentRegistry.js.map +0 -1
  325. package/dist/utils/frontmatter.d.ts +0 -10
  326. package/dist/utils/frontmatter.d.ts.map +0 -1
  327. package/dist/utils/frontmatter.js +0 -78
  328. package/dist/utils/frontmatter.js.map +0 -1
@@ -3,24 +3,15 @@
3
3
  *
4
4
  * Design principles:
5
5
  * - Single source of truth for input state
6
- * - Hybrid floating/scroll approach:
7
- * - Initially: chat box floats below content
8
- * - When terminal fills: scroll region activates, chat box pins to bottom
9
6
  * - Native bracketed paste support (no heuristics)
10
7
  * - Clean cursor model with render-time wrapping
11
8
  * - State machine for different input modes
12
9
  * - No readline dependency for display
13
- * - Text selection enabled: mouse tracking disabled by default to preserve
14
- * native terminal text selection and copy/paste functionality
15
- * - Scrollback navigation via keyboard: PageUp/PageDown, Ctrl+Home/End
16
10
  */
17
11
  import { EventEmitter } from 'node:events';
18
- import { isMultilinePaste } from '../core/multilinePasteHandler.js';
12
+ import { isMultilinePaste, generatePasteSummary } from '../core/multilinePasteHandler.js';
19
13
  import { writeLock } from '../ui/writeLock.js';
20
- import { renderDivider, renderStatusLine } from '../ui/unified/layout.js';
21
- import { isStreamingMode } from '../ui/globalWriteLock.js';
22
- import { formatThinking } from '../ui/toolDisplay.js';
23
- import { theme } from '../ui/theme.js';
14
+ import { UI_COLORS, DEFAULT_UI_CONFIG, } from '../ui/terminalUISchema.js';
24
15
  // ANSI escape codes
25
16
  const ESC = {
26
17
  // Cursor control
@@ -30,18 +21,14 @@ const ESC = {
30
21
  SHOW: '\x1b[?25h',
31
22
  TO: (row, col) => `\x1b[${row};${col}H`,
32
23
  TO_COL: (col) => `\x1b[${col}G`,
24
+ // Screen control
25
+ CLEAR_SCREEN: '\x1b[2J',
26
+ HOME: '\x1b[H',
27
+ ALT_SCREEN_ENTER: '\x1b[?1049h', // Enter alternate screen buffer
28
+ ALT_SCREEN_EXIT: '\x1b[?1049l', // Exit alternate screen buffer
33
29
  // Line control
34
30
  CLEAR_LINE: '\x1b[2K',
35
31
  CLEAR_TO_END: '\x1b[0J',
36
- // Screen control
37
- HOME: '\x1b[H',
38
- CLEAR_SCREEN: '\x1b[2J',
39
- // Alternate screen buffer (like vim/tmux)
40
- ENTER_ALT_SCREEN: '\x1b[?1049h',
41
- EXIT_ALT_SCREEN: '\x1b[?1049l',
42
- // Scroll region
43
- SET_SCROLL: (top, bottom) => `\x1b[${top};${bottom}r`,
44
- RESET_SCROLL: '\x1b[r',
45
32
  // Style
46
33
  RESET: '\x1b[0m',
47
34
  DIM: '\x1b[2m',
@@ -53,12 +40,6 @@ const ESC = {
53
40
  PASTE_DISABLE: '\x1b[?2004l',
54
41
  PASTE_START: '\x1b[200~',
55
42
  PASTE_END: '\x1b[201~',
56
- // Mouse tracking - Button events only (allows Shift+drag for text selection)
57
- // Mode 1000: Track button presses/releases (wheel events included)
58
- // Mode 1006: SGR extended format for better coordinate handling
59
- // Note: NOT using mode 1003 (all motion) to preserve terminal text selection
60
- MOUSE_ENABLE: '\x1b[?1000h\x1b[?1006h', // Enable button tracking + SGR mode
61
- MOUSE_DISABLE: '\x1b[?1006l\x1b[?1000l', // Disable mouse tracking
62
43
  };
63
44
  /**
64
45
  * Unified terminal input handler
@@ -87,54 +68,49 @@ export class TerminalInput extends EventEmitter {
87
68
  statusMessage = null;
88
69
  overrideStatusMessage = null; // Secondary status (warnings, etc.)
89
70
  streamingLabel = null; // Streaming progress indicator
90
- metaElapsedSeconds = null; // Optional elapsed time for header line
91
- metaTokensUsed = null; // Optional token usage
92
- metaTokenLimit = null; // Optional token window
93
- metaThinkingMs = null; // Optional thinking duration
94
- metaThinkingHasContent = false; // Whether collapsed thinking content exists
95
71
  lastRenderContent = '';
96
72
  lastRenderCursor = -1;
97
73
  renderDirty = false;
98
74
  isRendering = false;
75
+ flowModeRenderedLines = 0; // Track lines rendered for clearing
76
+ inputAreaStartRow = 0; // Track absolute row position of input area
77
+ contentEndRow = 0; // Row where content ends (chat box renders below this)
78
+ // Command suggestions (Claude Code style auto-complete)
79
+ commandSuggestions = [];
80
+ filteredSuggestions = [];
81
+ selectedSuggestionIndex = 0;
82
+ showSuggestions = false;
99
83
  // Lifecycle
100
84
  disposed = false;
101
85
  enabled = true;
102
86
  contextUsage = null;
103
- contextAutoCompactThreshold = 90;
104
- // Track current content row (starts at top, moves down)
105
- contentRow = 1;
106
- // Track if scroll region is currently active
107
- scrollRegionActive = false;
108
- thinkingModeLabel = null;
109
- // Scrollback buffer
110
- scrollbackBuffer = [];
111
- maxScrollbackLines = 10000;
112
- scrollbackOffset = 0; // 0 = at bottom (live), > 0 = scrolled up
113
- isInScrollbackMode = false;
114
- scrollIndicatorFrame = 0; // For animated scroll indicator
115
- // Alternate screen state
116
- alternateScreenActive = false;
117
87
  editMode = 'display-edits';
118
88
  verificationEnabled = true;
119
89
  autoContinueEnabled = false;
120
90
  verificationHotkey = 'alt+v';
121
91
  autoContinueHotkey = 'alt+c';
122
- thinkingHotkey = '/thinking';
123
- modelLabel = null;
124
- providerLabel = null;
125
- // Streaming render throttle
126
- lastStreamingRender = 0;
127
- streamingRenderInterval = 250; // ms between renders during streaming
92
+ // Output interceptor cleanup
93
+ outputInterceptorCleanup;
94
+ // Metrics tracking for status bar
95
+ streamingStartTime = null;
96
+ thinkingEnabled = true;
97
+ modelInfo = null; // Provider · Model info
98
+ // Streaming input area render timer (updates elapsed time display)
128
99
  streamingRenderTimer = null;
100
+ // Reference to display module for getting line counts during streaming
101
+ displayRef = null;
102
+ // Unified UI initialization flag
103
+ unifiedUIInitialized = false;
129
104
  constructor(writeStream = process.stdout, config = {}) {
130
105
  super();
131
106
  this.out = writeStream;
107
+ // Use schema defaults for configuration consistency
132
108
  this.config = {
133
- maxLines: config.maxLines ?? 1000,
134
- maxLength: config.maxLength ?? 10000,
109
+ maxLines: config.maxLines ?? DEFAULT_UI_CONFIG.inputArea.maxLines,
110
+ maxLength: config.maxLength ?? DEFAULT_UI_CONFIG.inputArea.maxLength,
135
111
  maxQueueSize: config.maxQueueSize ?? 100,
136
- promptChar: config.promptChar ?? '> ',
137
- continuationChar: config.continuationChar ?? '│ ',
112
+ promptChar: config.promptChar ?? DEFAULT_UI_CONFIG.inputArea.promptChar,
113
+ continuationChar: config.continuationChar ?? DEFAULT_UI_CONFIG.inputArea.continuationChar,
138
114
  };
139
115
  }
140
116
  // ===========================================================================
@@ -157,47 +133,10 @@ export class TerminalInput extends EventEmitter {
157
133
  }
158
134
  }
159
135
  /**
160
- * Enable mouse tracking in terminal
161
- */
162
- enableMouseTracking() {
163
- if (this.isTTY()) {
164
- this.write(ESC.MOUSE_ENABLE);
165
- }
166
- }
167
- /**
168
- * Disable mouse tracking
169
- */
170
- disableMouseTracking() {
171
- if (this.isTTY()) {
172
- this.write(ESC.MOUSE_DISABLE);
173
- }
174
- }
175
- /**
176
- * Process raw terminal data (handles bracketed paste sequences, mouse events, and escape sequences)
177
- * Returns true if the data was consumed (paste sequence, mouse event, etc.)
136
+ * Process raw terminal data (handles bracketed paste sequences)
137
+ * Returns true if the data was consumed (paste sequence)
178
138
  */
179
139
  processRawData(data) {
180
- // Check for mouse events (SGR mode: \x1b[<button;x;yM or m)
181
- const mouseMatch = data.match(/\x1b\[<(\d+);(\d+);(\d+)([Mm])/);
182
- if (mouseMatch) {
183
- const button = parseInt(mouseMatch[1], 10);
184
- const x = parseInt(mouseMatch[2], 10);
185
- const y = parseInt(mouseMatch[3], 10);
186
- const action = mouseMatch[4]; // 'M' = press, 'm' = release
187
- this.handleMouseEvent(button, x, y, action);
188
- // Remove mouse event from data and return remaining
189
- const mouseEventEnd = data.indexOf(action) + 1;
190
- const remaining = data.slice(mouseEventEnd);
191
- return { consumed: true, passthrough: remaining };
192
- }
193
- // Filter out arrow key escape sequences (they're handled by keypress events)
194
- // Arrow keys: \x1b[A (up), \x1b[B (down), \x1b[C (right), \x1b[D (left)
195
- const arrowMatch = data.match(/\x1b\[[ABCD]/);
196
- if (arrowMatch) {
197
- // Arrow keys should be handled by keypress handler, strip them from passthrough
198
- const filtered = data.replace(/\x1b\[[ABCD]/g, '');
199
- return { consumed: filtered.length !== data.length, passthrough: filtered };
200
- }
201
140
  // Check for paste start
202
141
  if (data.includes(ESC.PASTE_START)) {
203
142
  this.isPasting = true;
@@ -228,21 +167,6 @@ export class TerminalInput extends EventEmitter {
228
167
  }
229
168
  return { consumed: false, passthrough: data };
230
169
  }
231
- /**
232
- * Handle mouse events (button, x, y coordinates, action)
233
- */
234
- handleMouseEvent(button, _x, _y, _action) {
235
- // Mouse wheel events: button 64 = scroll up, button 65 = scroll down
236
- if (button === 64) {
237
- // Scroll up (3 lines per wheel tick)
238
- this.scrollUp(3);
239
- }
240
- else if (button === 65) {
241
- // Scroll down (3 lines per wheel tick)
242
- this.scrollDown(3);
243
- }
244
- // Ignore other mouse events (clicks, drags, etc.) for now
245
- }
246
170
  /**
247
171
  * Handle a keypress event
248
172
  */
@@ -265,36 +189,258 @@ export class TerminalInput extends EventEmitter {
265
189
  if (handled)
266
190
  return;
267
191
  }
192
+ // Handle '?' for help hint (if buffer is empty)
193
+ if (str === '?' && this.buffer.length === 0) {
194
+ this.emit('showHelp');
195
+ return;
196
+ }
268
197
  // Insert printable characters
269
198
  if (str && !key?.ctrl && !key?.meta) {
270
199
  this.insertText(str);
271
200
  }
272
201
  }
202
+ // Banner content to write on init (set via setBannerContent before initializeUnifiedUI)
203
+ bannerContent = null;
204
+ /**
205
+ * Set banner content to be written when unified UI initializes.
206
+ */
207
+ setBannerContent(content) {
208
+ this.bannerContent = content;
209
+ }
210
+ /**
211
+ * Initialize the unified UI system with BOTTOM PINNED chat box.
212
+ *
213
+ * Layout:
214
+ * 1. Clear screen
215
+ * 2. Write banner at top
216
+ * 3. Set content cursor row after banner
217
+ * 4. Render chat box at bottom (sets up scroll region)
218
+ */
219
+ initializeUnifiedUI() {
220
+ if (this.unifiedUIInitialized) {
221
+ return;
222
+ }
223
+ // Enter alternate screen buffer for complete terminal control
224
+ this.write(ESC.ALT_SCREEN_ENTER);
225
+ // Hide cursor during setup
226
+ this.write(ESC.HIDE);
227
+ // Clear screen and go home (in alternate buffer)
228
+ this.write(ESC.HOME);
229
+ this.write(ESC.CLEAR_SCREEN);
230
+ // Write banner at top
231
+ let bannerLines = 0;
232
+ if (this.bannerContent) {
233
+ const lines = this.bannerContent.split('\n');
234
+ bannerLines = lines.length + 2; // +2 for trailing \n\n
235
+ process.stdout.write(this.bannerContent + '\n\n');
236
+ }
237
+ // Set content cursor row after banner
238
+ this.contentCursorRow = bannerLines > 0 ? bannerLines + 1 : 1;
239
+ // Content ends at same row initially (no content yet)
240
+ this.contentEndRow = this.contentCursorRow - 1;
241
+ // Mark initialized
242
+ this.unifiedUIInitialized = true;
243
+ // Render floating chat box below content
244
+ this.renderFloatingInputArea();
245
+ }
246
+ /**
247
+ * Clear the input area at its tracked position.
248
+ * Returns true if something was cleared.
249
+ */
250
+ clearInputArea() {
251
+ if (this.inputAreaStartRow > 0 && this.flowModeRenderedLines > 0) {
252
+ for (let i = 0; i < this.flowModeRenderedLines; i++) {
253
+ this.write(ESC.TO(this.inputAreaStartRow + i, 1));
254
+ this.write(ESC.CLEAR_LINE);
255
+ }
256
+ return true;
257
+ }
258
+ return false;
259
+ }
260
+ /**
261
+ * Reset input area tracking state.
262
+ */
263
+ resetInputAreaTracking() {
264
+ this.inputAreaStartRow = 0;
265
+ this.flowModeRenderedLines = 0;
266
+ }
267
+ /**
268
+ * Render chat box - FLOATING below content (no scroll regions).
269
+ * Chat box appears right after content and moves down as content grows.
270
+ * Content and banner scroll naturally off the top of the screen.
271
+ */
272
+ renderFloatingInputArea() {
273
+ const { cols, rows } = this.getSize();
274
+ const layout = this.buildInputLayout(cols);
275
+ // Clamp start row so the full UI stays visible, even when content exceeds viewport
276
+ const chatBoxHeight = layout.lines.length;
277
+ const chatBoxStartRow = Math.max(1, Math.min(this.contentEndRow + 1, rows - chatBoxHeight + 1));
278
+ // Save cursor position (content writer), render, then restore to avoid disrupting output
279
+ this.write(ESC.SAVE);
280
+ this.write(ESC.HIDE);
281
+ this.inputAreaStartRow = chatBoxStartRow;
282
+ let currentRow = chatBoxStartRow;
283
+ // Render each line with clearing to avoid ghosting
284
+ for (const line of layout.lines) {
285
+ this.write(ESC.TO(currentRow, 1));
286
+ this.write(ESC.CLEAR_LINE);
287
+ this.write(this.fitToWidth(line, cols));
288
+ currentRow++;
289
+ }
290
+ this.flowModeRenderedLines = chatBoxHeight;
291
+ this.lastRenderContent = this.buffer;
292
+ this.lastRenderCursor = this.cursor;
293
+ // Restore cursor for content writes; caret is rendered virtually inside the prompt
294
+ this.write(ESC.RESTORE);
295
+ this.write(ESC.SHOW);
296
+ }
273
297
  /**
274
298
  * Set the input mode
275
299
  *
276
- * Content flows naturally - no scroll region pinning.
300
+ * BOTTOM PINNED with SSE: Chat box stays at terminal bottom.
301
+ * Scroll region protects chat box, content scrolls above it.
277
302
  */
278
303
  setMode(mode) {
279
304
  const prevMode = this.mode;
280
305
  this.mode = mode;
281
306
  if (mode === 'streaming' && prevMode !== 'streaming') {
282
- this.resetStreamingRenderThrottle();
307
+ // Track streaming start time for elapsed display
308
+ this.streamingStartTime = Date.now();
309
+ // Ensure unified UI is initialized
310
+ if (!this.unifiedUIInitialized) {
311
+ this.initializeUnifiedUI();
312
+ }
313
+ // Start periodic render timer to keep chat box updated during streaming
314
+ // This updates contentEndRow from display and re-renders chat box
315
+ if (!this.streamingRenderTimer) {
316
+ this.streamingRenderTimer = setInterval(() => {
317
+ // Update contentEndRow from display's line count
318
+ if (this.displayRef?.getTotalWrittenLines) {
319
+ const next = this.displayRef.getTotalWrittenLines();
320
+ if (typeof next === 'number' && Number.isFinite(next) && next !== this.contentEndRow) {
321
+ this.contentEndRow = next;
322
+ this.renderDirty = true;
323
+ }
324
+ }
325
+ // Re-render chat box at updated position
326
+ if (this.renderDirty) {
327
+ this.render();
328
+ }
329
+ }, 120); // Update periodically without overwhelming output
330
+ }
331
+ // Initial render
283
332
  this.renderDirty = true;
284
- this.render();
333
+ this.scheduleRender();
285
334
  }
286
335
  else if (mode !== 'streaming' && prevMode === 'streaming') {
287
- // Streaming ended - render the input area
288
- this.resetStreamingRenderThrottle();
289
- this.forceRender();
336
+ // Stop streaming render timer
337
+ if (this.streamingRenderTimer) {
338
+ clearInterval(this.streamingRenderTimer);
339
+ this.streamingRenderTimer = null;
340
+ }
341
+ // Reset streaming time
342
+ this.streamingStartTime = null;
343
+ // Final render with accurate position
344
+ this.renderDirty = true;
345
+ this.scheduleRender();
346
+ }
347
+ }
348
+ /**
349
+ * Set the row where content ends (for idle mode positioning).
350
+ * Input area will render starting from this row + 1.
351
+ */
352
+ setContentEndRow(row) {
353
+ this.contentEndRow = Math.max(0, row);
354
+ this.renderDirty = true;
355
+ this.scheduleRender();
356
+ }
357
+ /**
358
+ * Set available slash commands for auto-complete suggestions.
359
+ */
360
+ setCommands(commands) {
361
+ this.commandSuggestions = commands;
362
+ this.updateSuggestions();
363
+ }
364
+ /**
365
+ * Update filtered suggestions based on current input.
366
+ */
367
+ updateSuggestions() {
368
+ const input = this.buffer.trim();
369
+ // Only show suggestions when input starts with "/"
370
+ if (!input.startsWith('/')) {
371
+ this.showSuggestions = false;
372
+ this.filteredSuggestions = [];
373
+ this.selectedSuggestionIndex = 0;
374
+ return;
375
+ }
376
+ const query = input.toLowerCase();
377
+ this.filteredSuggestions = this.commandSuggestions.filter(cmd => cmd.command.toLowerCase().startsWith(query) ||
378
+ cmd.command.toLowerCase().includes(query.slice(1)));
379
+ // Show suggestions if we have matches
380
+ this.showSuggestions = this.filteredSuggestions.length > 0;
381
+ // Keep selection in bounds
382
+ if (this.selectedSuggestionIndex >= this.filteredSuggestions.length) {
383
+ this.selectedSuggestionIndex = Math.max(0, this.filteredSuggestions.length - 1);
290
384
  }
291
385
  }
292
386
  /**
293
- * Legacy method - no longer used (content flows naturally).
294
- * @deprecated Use setContentRow instead
387
+ * Select next suggestion (arrow down / tab).
388
+ */
389
+ selectNextSuggestion() {
390
+ if (!this.showSuggestions || this.filteredSuggestions.length === 0)
391
+ return;
392
+ this.selectedSuggestionIndex = (this.selectedSuggestionIndex + 1) % this.filteredSuggestions.length;
393
+ this.renderDirty = true;
394
+ this.scheduleRender();
395
+ }
396
+ /**
397
+ * Select previous suggestion (arrow up / shift+tab).
398
+ */
399
+ selectPrevSuggestion() {
400
+ if (!this.showSuggestions || this.filteredSuggestions.length === 0)
401
+ return;
402
+ this.selectedSuggestionIndex = this.selectedSuggestionIndex === 0
403
+ ? this.filteredSuggestions.length - 1
404
+ : this.selectedSuggestionIndex - 1;
405
+ this.renderDirty = true;
406
+ this.scheduleRender();
407
+ }
408
+ /**
409
+ * Accept current suggestion and insert into buffer.
410
+ */
411
+ acceptSuggestion() {
412
+ if (!this.showSuggestions || this.filteredSuggestions.length === 0)
413
+ return false;
414
+ const selected = this.filteredSuggestions[this.selectedSuggestionIndex];
415
+ if (!selected)
416
+ return false;
417
+ // Replace buffer with selected command
418
+ this.buffer = selected.command + ' ';
419
+ this.cursor = this.buffer.length;
420
+ this.showSuggestions = false;
421
+ this.renderDirty = true;
422
+ this.scheduleRender();
423
+ return true;
424
+ }
425
+ /**
426
+ * Check if suggestions are visible.
427
+ */
428
+ areSuggestionsVisible() {
429
+ return this.showSuggestions && this.filteredSuggestions.length > 0;
430
+ }
431
+ /**
432
+ * Toggle thinking/reasoning mode
433
+ */
434
+ toggleThinking() {
435
+ this.thinkingEnabled = !this.thinkingEnabled;
436
+ this.emit('thinkingToggle', this.thinkingEnabled);
437
+ this.scheduleRender();
438
+ }
439
+ /**
440
+ * Get thinking enabled state
295
441
  */
296
- setPinnedHeaderLines(_count) {
297
- // No-op: scroll region pinning removed
442
+ isThinkingEnabled() {
443
+ return this.thinkingEnabled;
298
444
  }
299
445
  /**
300
446
  * Get current mode
@@ -327,14 +473,17 @@ export class TerminalInput extends EventEmitter {
327
473
  }
328
474
  /**
329
475
  * Clear the buffer
476
+ * @param skipRender - If true, don't trigger a re-render (used during submit flow)
330
477
  */
331
- clear() {
478
+ clear(skipRender = false) {
332
479
  this.buffer = '';
333
480
  this.cursor = 0;
334
481
  this.historyIndex = -1;
335
482
  this.tempInput = '';
336
483
  this.pastePlaceholders = [];
337
- this.scheduleRender();
484
+ if (!skipRender) {
485
+ this.scheduleRender();
486
+ }
338
487
  }
339
488
  /**
340
489
  * Get queued inputs
@@ -405,37 +554,6 @@ export class TerminalInput extends EventEmitter {
405
554
  this.streamingLabel = next;
406
555
  this.scheduleRender();
407
556
  }
408
- /**
409
- * Surface meta status just above the divider (e.g., elapsed time or token usage).
410
- */
411
- setMetaStatus(meta) {
412
- const nextElapsed = typeof meta.elapsedSeconds === 'number' && Number.isFinite(meta.elapsedSeconds) && meta.elapsedSeconds >= 0
413
- ? Math.floor(meta.elapsedSeconds)
414
- : null;
415
- const nextTokens = typeof meta.tokensUsed === 'number' && Number.isFinite(meta.tokensUsed) && meta.tokensUsed >= 0
416
- ? Math.floor(meta.tokensUsed)
417
- : null;
418
- const nextLimit = typeof meta.tokenLimit === 'number' && Number.isFinite(meta.tokenLimit) && meta.tokenLimit > 0
419
- ? Math.floor(meta.tokenLimit)
420
- : null;
421
- const nextThinking = typeof meta.thinkingMs === 'number' && Number.isFinite(meta.thinkingMs) && meta.thinkingMs >= 0
422
- ? Math.floor(meta.thinkingMs)
423
- : null;
424
- const nextThinkingHasContent = !!meta.thinkingHasContent;
425
- if (this.metaElapsedSeconds === nextElapsed &&
426
- this.metaTokensUsed === nextTokens &&
427
- this.metaTokenLimit === nextLimit &&
428
- this.metaThinkingMs === nextThinking &&
429
- this.metaThinkingHasContent === nextThinkingHasContent) {
430
- return;
431
- }
432
- this.metaElapsedSeconds = nextElapsed;
433
- this.metaTokensUsed = nextTokens;
434
- this.metaTokenLimit = nextLimit;
435
- this.metaThinkingMs = nextThinking;
436
- this.metaThinkingHasContent = nextThinkingHasContent;
437
- this.scheduleRender();
438
- }
439
557
  /**
440
558
  * Keep mode toggles (verification/auto-continue) visible in the control bar.
441
559
  * Hotkey labels remain stable so the bar looks the same before/during streaming.
@@ -445,22 +563,26 @@ export class TerminalInput extends EventEmitter {
445
563
  const nextAutoContinue = !!options.autoContinueEnabled;
446
564
  const nextVerifyHotkey = options.verificationHotkey ?? this.verificationHotkey;
447
565
  const nextAutoHotkey = options.autoContinueHotkey ?? this.autoContinueHotkey;
448
- const nextThinkingHotkey = options.thinkingHotkey ?? this.thinkingHotkey;
449
- const nextThinkingLabel = options.thinkingModeLabel === undefined ? this.thinkingModeLabel : (options.thinkingModeLabel || null);
450
566
  if (this.verificationEnabled === nextVerification &&
451
567
  this.autoContinueEnabled === nextAutoContinue &&
452
568
  this.verificationHotkey === nextVerifyHotkey &&
453
- this.autoContinueHotkey === nextAutoHotkey &&
454
- this.thinkingHotkey === nextThinkingHotkey &&
455
- this.thinkingModeLabel === nextThinkingLabel) {
569
+ this.autoContinueHotkey === nextAutoHotkey) {
456
570
  return;
457
571
  }
458
572
  this.verificationEnabled = nextVerification;
459
573
  this.autoContinueEnabled = nextAutoContinue;
460
574
  this.verificationHotkey = nextVerifyHotkey;
461
575
  this.autoContinueHotkey = nextAutoHotkey;
462
- this.thinkingHotkey = nextThinkingHotkey;
463
- this.thinkingModeLabel = nextThinkingLabel;
576
+ this.scheduleRender();
577
+ }
578
+ /**
579
+ * Set the model info string (e.g., "OpenAI · gpt-4")
580
+ * This is displayed persistently above the input area.
581
+ */
582
+ setModelInfo(info) {
583
+ if (this.modelInfo === info)
584
+ return;
585
+ this.modelInfo = info;
464
586
  this.scheduleRender();
465
587
  }
466
588
  /**
@@ -473,174 +595,33 @@ export class TerminalInput extends EventEmitter {
473
595
  this.scheduleRender();
474
596
  }
475
597
  /**
476
- * Surface model/provider context in the controls bar.
477
- */
478
- setModelContext(options) {
479
- const nextModel = options.model?.trim() || null;
480
- const nextProvider = options.provider?.trim() || null;
481
- if (this.modelLabel === nextModel && this.providerLabel === nextProvider) {
482
- return;
483
- }
484
- this.modelLabel = nextModel;
485
- this.providerLabel = nextProvider;
486
- this.scheduleRender();
487
- }
488
- /**
489
- * Render the floating input area at contentRow.
490
- *
491
- * The chat box "floats" - it renders right below the last streamed content.
492
- * As content is added, contentRow advances, and the chat box moves down.
493
- * No scroll regions - pure floating behavior.
598
+ * Render the input area.
599
+ * During streaming: renders at terminal bottom (with scroll region)
600
+ * After streaming: renders floating below content
494
601
  */
495
602
  render() {
496
603
  if (!this.canRender())
497
604
  return;
498
605
  if (this.isRendering)
499
606
  return;
500
- const streamingActive = this.mode === 'streaming' || isStreamingMode();
501
- // During streaming, throttle re-renders
502
- if (streamingActive && this.lastStreamingRender > 0) {
503
- const elapsed = Date.now() - this.lastStreamingRender;
504
- const waitMs = Math.max(0, this.streamingRenderInterval - elapsed);
505
- if (waitMs > 0) {
506
- this.renderDirty = true;
507
- this.scheduleStreamingRender(waitMs);
508
- return;
509
- }
510
- }
511
607
  const shouldSkip = !this.renderDirty &&
512
608
  this.buffer === this.lastRenderContent &&
513
609
  this.cursor === this.lastRenderCursor;
514
610
  this.renderDirty = false;
611
+ // Skip if nothing changed (unless explicitly forced)
515
612
  if (shouldSkip) {
516
613
  return;
517
614
  }
615
+ // If write lock is held, defer render
518
616
  if (writeLock.isLocked()) {
519
617
  writeLock.safeWrite(() => this.render());
520
618
  return;
521
619
  }
522
- this.renderPinnedChatBox();
523
- }
524
- /**
525
- * Unified scroll region renderer.
526
- * Chat box is ALWAYS pinned at the bottom of the terminal.
527
- * Content scrolls in the region above the chat box.
528
- */
529
- renderPinnedChatBox() {
530
- const { rows, cols } = this.getSize();
531
- const maxWidth = Math.max(8, cols - 4);
532
- const streamingActive = this.mode === 'streaming' || isStreamingMode();
533
- // Wrap buffer into display lines
534
- const { lines, cursorLine, cursorCol } = this.wrapBuffer(maxWidth);
535
- const maxVisible = Math.max(1, Math.min(this.config.maxLines, rows - 3));
536
- const displayLines = Math.min(lines.length, maxVisible);
537
- const metaLines = this.buildMetaLines(cols - 2);
538
- // Calculate display window (keep cursor visible)
539
- let startLine = 0;
540
- if (lines.length > displayLines) {
541
- startLine = Math.max(0, cursorLine - displayLines + 1);
542
- startLine = Math.min(startLine, lines.length - displayLines);
543
- }
544
- const visibleLines = lines.slice(startLine, startLine + displayLines);
545
- const adjustedCursorLine = cursorLine - startLine;
546
- // Chat box height
547
- const chatBoxHeight = this.getChatBoxHeight();
548
- // ALWAYS pin chat box at absolute bottom
549
- const chatBoxStartRow = Math.max(1, rows - chatBoxHeight + 1);
550
- const scrollEnd = chatBoxStartRow - 1;
551
- writeLock.lock('terminalInput.renderPinned');
552
620
  this.isRendering = true;
621
+ writeLock.lock('terminalInput.render');
553
622
  try {
554
- this.write(ESC.SAVE);
555
- this.write(ESC.HIDE);
556
- // Temporarily reset scroll region to write chat box cleanly
557
- if (this.scrollRegionActive) {
558
- this.write(ESC.RESET_SCROLL);
559
- }
560
- // Clear the chat box area
561
- for (let i = 0; i < chatBoxHeight; i++) {
562
- const row = chatBoxStartRow + i;
563
- if (row <= rows) {
564
- this.write(ESC.TO(row, 1));
565
- this.write(ESC.CLEAR_LINE);
566
- }
567
- }
568
- let currentRow = chatBoxStartRow;
569
- // Render scroll/status indicator on the left (Claude Code style)
570
- const scrollIndicator = this.buildScrollIndicator();
571
- // Meta/status header with scroll indicator
572
- for (const metaLine of metaLines) {
573
- this.write(ESC.TO(currentRow, 1));
574
- this.write(metaLine);
575
- currentRow += 1;
576
- }
577
- // Separator line with scroll status
578
- this.write(ESC.TO(currentRow, 1));
579
- const dividerLabel = scrollIndicator || undefined;
580
- this.write(renderDivider(cols - 2, dividerLabel));
581
- currentRow += 1;
582
- // Render input lines
583
- let finalRow = currentRow;
584
- let finalCol = 3;
585
- for (let i = 0; i < visibleLines.length; i++) {
586
- const rowNum = currentRow + i;
587
- this.write(ESC.TO(rowNum, 1));
588
- const line = visibleLines[i] ?? '';
589
- const isFirstLine = (startLine + i) === 0;
590
- const isCursorLine = i === adjustedCursorLine;
591
- this.write(ESC.BG_DARK);
592
- this.write(ESC.DIM);
593
- this.write(isFirstLine ? this.config.promptChar : this.config.continuationChar);
594
- this.write(ESC.RESET);
595
- this.write(ESC.BG_DARK);
596
- if (isCursorLine) {
597
- const col = Math.min(cursorCol, line.length);
598
- const before = line.slice(0, col);
599
- const at = col < line.length ? line[col] : ' ';
600
- const after = col < line.length ? line.slice(col + 1) : '';
601
- this.write(before);
602
- this.write(ESC.REVERSE + ESC.BOLD);
603
- this.write(at);
604
- this.write(ESC.RESET + ESC.BG_DARK);
605
- this.write(after);
606
- finalRow = rowNum;
607
- finalCol = this.config.promptChar.length + col + 1;
608
- }
609
- else {
610
- this.write(line);
611
- }
612
- // Pad to edge
613
- const lineLen = this.config.promptChar.length + line.length + (isCursorLine && cursorCol >= line.length ? 1 : 0);
614
- const padding = Math.max(0, cols - lineLen - 1);
615
- if (padding > 0)
616
- this.write(' '.repeat(padding));
617
- this.write(ESC.RESET);
618
- }
619
- // Mode controls line with all keyboard shortcuts
620
- const controlRow = currentRow + visibleLines.length;
621
- this.write(ESC.TO(controlRow, 1));
622
- this.write(this.buildModeControls(cols));
623
- // Restore scroll region and cursor
624
- if (this.scrollRegionActive) {
625
- // Restore scroll region
626
- this.write(ESC.SET_SCROLL(1, scrollEnd));
627
- // Restore cursor to where it was before rendering (preserves column position)
628
- this.write(ESC.RESTORE);
629
- }
630
- else {
631
- // Not streaming - position cursor in input box
632
- this.write(ESC.RESTORE);
633
- this.write(ESC.TO(finalRow, Math.min(finalCol, cols)));
634
- }
635
- this.write(ESC.SHOW);
636
- // Update state
637
- this.lastRenderContent = this.buffer;
638
- this.lastRenderCursor = this.cursor;
639
- this.lastStreamingRender = streamingActive ? Date.now() : 0;
640
- if (this.streamingRenderTimer) {
641
- clearTimeout(this.streamingRenderTimer);
642
- this.streamingRenderTimer = null;
643
- }
623
+ // Always render floating right after content (no wasted space)
624
+ this.renderFloatingInputArea();
644
625
  }
645
626
  finally {
646
627
  writeLock.unlock();
@@ -648,179 +629,211 @@ export class TerminalInput extends EventEmitter {
648
629
  }
649
630
  }
650
631
  /**
651
- * Build compact meta line above the divider.
652
- * Shows model/provider and key metrics in a single line.
632
+ * Build the structured input layout according to the UI schema.
653
633
  */
654
- buildMetaLines(width) {
655
- const leftParts = [];
656
- const rightParts = [];
657
- // Model/provider info (left side)
658
- if (this.modelLabel) {
659
- const modelText = this.providerLabel
660
- ? `${this.modelLabel} @ ${this.providerLabel}`
661
- : this.modelLabel;
662
- leftParts.push({ text: modelText, tone: 'info' });
663
- }
664
- // Elapsed time (right side)
665
- if (this.metaElapsedSeconds !== null) {
666
- rightParts.push({ text: `⏱ ${this.formatElapsedLabel(this.metaElapsedSeconds)}`, tone: 'muted' });
667
- }
668
- // Token usage (right side)
669
- if (this.metaTokensUsed !== null) {
670
- const formattedUsed = this.formatTokenCount(this.metaTokensUsed);
671
- const formattedLimit = this.metaTokenLimit ? `/${this.formatTokenCount(this.metaTokenLimit)}` : '';
672
- rightParts.push({ text: `⊛ ${formattedUsed}${formattedLimit}`, tone: 'muted' });
673
- }
674
- // Context remaining warning
675
- const tokensRemaining = this.computeTokensRemaining();
676
- if (tokensRemaining !== null) {
677
- const contextPct = this.contextUsage !== null ? `${100 - this.contextUsage}%` : '';
678
- rightParts.push({ text: `↓${tokensRemaining} ${contextPct}`, tone: this.contextUsage && this.contextUsage > 80 ? 'warn' : 'muted' });
679
- }
680
- // Thinking indicator
681
- if (this.metaThinkingMs !== null) {
682
- leftParts.push({ text: formatThinking(this.metaThinkingMs, this.metaThinkingHasContent), tone: 'info' });
683
- }
684
- if (!leftParts.length && !rightParts.length) {
634
+ buildInputLayout(cols) {
635
+ const width = Math.min(cols, Math.max(DEFAULT_UI_CONFIG.banner.minWidth, Math.min(DEFAULT_UI_CONFIG.banner.maxWidth, cols)));
636
+ const lines = [];
637
+ lines.push(this.buildStatusBar(width));
638
+ lines.push(this.buildDivider(width));
639
+ lines.push(...this.buildInputLines(width));
640
+ const suggestions = this.buildSuggestions(width);
641
+ if (suggestions.length > 0) {
642
+ lines.push(...suggestions);
643
+ }
644
+ lines.push(this.buildDivider(width));
645
+ lines.push(this.buildModeControls(width));
646
+ return { lines };
647
+ }
648
+ buildDivider(width) {
649
+ const lineWidth = Math.max(8, Math.min(width, DEFAULT_UI_CONFIG.banner.maxWidth));
650
+ return `${UI_COLORS.dim}${DEFAULT_UI_CONFIG.divider.char.repeat(lineWidth)}${UI_COLORS.reset}`;
651
+ }
652
+ buildInputLines(width) {
653
+ const prompt = `${UI_COLORS.dim}${this.config.promptChar}${UI_COLORS.reset}`;
654
+ const continuation = `${UI_COLORS.dim}${this.config.continuationChar}${UI_COLORS.reset}`;
655
+ const promptWidth = this.visibleLength(prompt);
656
+ const textWidth = Math.max(1, width - promptWidth);
657
+ const { lines, cursorLine, cursorCol } = this.wrapBuffer(textWidth);
658
+ const maxLines = Math.max(1, this.config.maxLines);
659
+ const startLine = Math.max(0, lines.length - maxLines);
660
+ const visibleLines = lines.slice(startLine);
661
+ const caretLine = Math.max(0, cursorLine - startLine);
662
+ if (!visibleLines.length) {
663
+ visibleLines.push('');
664
+ }
665
+ return visibleLines.map((lineText, index) => {
666
+ const hasCursor = caretLine === index;
667
+ const cursorIndex = hasCursor ? cursorCol : null;
668
+ const prefix = index === 0 ? prompt : continuation;
669
+ return this.renderPromptLine(prefix, lineText, cursorIndex, textWidth, width);
670
+ });
671
+ }
672
+ buildSuggestions(width) {
673
+ if (!this.areSuggestionsVisible()) {
685
674
  return [];
686
675
  }
687
- // Render left and right aligned
688
- if (!rightParts.length || width < 50) {
689
- return [renderStatusLine([...leftParts, ...rightParts], width)];
690
- }
691
- const leftWidth = Math.max(12, Math.floor(width * 0.5));
692
- const rightWidth = Math.max(14, width - leftWidth - 1);
693
- const leftText = renderStatusLine(leftParts, leftWidth);
694
- const rightText = renderStatusLine(rightParts, rightWidth);
695
- const spacing = Math.max(1, width - this.visibleLength(leftText) - this.visibleLength(rightText));
696
- return [`${leftText}${' '.repeat(spacing)}${rightText}`];
676
+ const lines = [];
677
+ const maxSuggestions = Math.min(3, this.filteredSuggestions.length);
678
+ lines.push(this.fitToWidth(`${UI_COLORS.dim}Suggestions${UI_COLORS.reset}`, width));
679
+ for (let i = 0; i < maxSuggestions; i++) {
680
+ const suggestion = this.filteredSuggestions[i];
681
+ if (!suggestion)
682
+ continue;
683
+ const isSelected = i === this.selectedSuggestionIndex;
684
+ const bullet = isSelected ? `${UI_COLORS.cyan}›${UI_COLORS.reset}` : `${UI_COLORS.dim}·${UI_COLORS.reset}`;
685
+ const body = `${suggestion.command} ${UI_COLORS.dim}${suggestion.description}${UI_COLORS.reset}`.trim();
686
+ lines.push(this.fitToWidth(`${bullet} ${body}`, width));
687
+ }
688
+ return lines;
689
+ }
690
+ renderPromptLine(prefix, text, cursorCol, textWidth, totalWidth) {
691
+ const visibleTextWidth = Math.max(1, textWidth);
692
+ const padded = (text ?? '').slice(0, visibleTextWidth).padEnd(visibleTextWidth, ' ');
693
+ let rendered = padded;
694
+ if (cursorCol !== null && cursorCol >= 0) {
695
+ const safeCol = Math.max(0, Math.min(cursorCol, Math.max(0, visibleTextWidth - 1)));
696
+ const before = padded.slice(0, safeCol);
697
+ const caretChar = padded[safeCol] ?? ' ';
698
+ const after = padded.slice(safeCol + 1);
699
+ rendered = `${before}${UI_COLORS.reverse}${caretChar || ' '}${UI_COLORS.reset}${after}`;
700
+ }
701
+ return this.fitToWidth(`${prefix}${rendered}`, totalWidth);
697
702
  }
698
- /**
699
- * Build mode controls line with status and key shortcuts.
700
- * Compact single line with essential info.
701
- */
702
- buildModeControls(cols) {
703
- const width = Math.max(8, cols - 2);
704
- const parts = [];
705
- // Streaming indicator
706
- if (this.streamingLabel) {
707
- parts.push({ text: `◐ ${this.streamingLabel}`, tone: 'info' });
708
- }
709
- // Interrupt hint (during streaming)
710
- if (this.mode === 'streaming' || this.scrollRegionActive) {
711
- parts.push({ text: `[Esc] stop`, tone: 'warn' });
712
- }
713
- // Scrollback indicator
714
- if (this.isInScrollbackMode && this.scrollbackOffset > 0) {
715
- parts.push({ text: `↕${this.scrollbackOffset}L`, tone: 'info' });
716
- }
717
- else if (this.scrollbackBuffer.length > 50) {
718
- parts.push({ text: `↕${this.scrollbackBuffer.length}L`, tone: 'muted' });
719
- }
720
- // Toggle states (compact)
721
- const editIcon = this.editMode === 'display-edits' ? '✓' : '?';
722
- const verifyIcon = this.verificationEnabled ? '✓' : '○';
723
- const autoIcon = this.autoContinueEnabled ? '✓' : '○';
724
- parts.push({
725
- text: `edits${editIcon} verify${verifyIcon} auto${autoIcon}`,
726
- tone: 'muted'
727
- });
728
- // Context remaining
729
- const contextRemaining = this.computeContextRemaining();
730
- if (contextRemaining !== null && contextRemaining <= 50) {
731
- const tone = contextRemaining <= 10 ? 'warn' : 'info';
732
- parts.push({ text: `${contextRemaining}%`, tone });
733
- }
734
- // Model info (when not streaming)
735
- if (this.modelLabel && !this.streamingLabel) {
736
- const shortModel = this.modelLabel.length > 20 ? this.modelLabel.slice(0, 18) + '..' : this.modelLabel;
737
- parts.push({ text: shortModel, tone: 'muted' });
738
- }
739
- return renderStatusLine(parts, width);
740
- }
741
- formatHotkey(hotkey) {
742
- const normalized = hotkey.trim().toLowerCase();
743
- if (!normalized)
744
- return hotkey;
745
- const parts = normalized.split('+').filter(Boolean);
746
- // Use readable key names instead of symbols for better terminal compatibility
747
- const map = {
748
- shift: 'Shift',
749
- sh: 'Shift',
750
- alt: 'Alt',
751
- option: 'Alt',
752
- opt: 'Alt',
753
- ctrl: 'Ctrl',
754
- control: 'Ctrl',
755
- cmd: 'Cmd',
756
- meta: 'Cmd',
757
- esc: 'Esc',
758
- escape: 'Esc',
759
- tab: 'Tab',
760
- return: 'Enter',
761
- enter: 'Enter',
762
- pageup: 'PgUp',
763
- pagedown: 'PgDn',
764
- home: 'Home',
765
- end: 'End',
766
- };
767
- const formatted = parts
768
- .map((part) => {
769
- const label = map[part];
770
- if (label)
771
- return label;
772
- return part.length === 1 ? part.toUpperCase() : part.charAt(0).toUpperCase() + part.slice(1);
773
- })
774
- .join('+');
775
- return `[${formatted}]`;
703
+ visibleLength(value) {
704
+ return value.replace(/\x1b\[[0-9;]*m/g, '').length;
776
705
  }
777
- computeContextRemaining() {
778
- if (this.contextUsage === null) {
779
- return null;
706
+ fitToWidth(value, width) {
707
+ const target = Math.max(1, width);
708
+ const visible = this.visibleLength(value);
709
+ if (visible === target) {
710
+ return value;
780
711
  }
781
- return Math.max(0, this.contextAutoCompactThreshold - this.contextUsage);
782
- }
783
- computeTokensRemaining() {
784
- if (this.metaTokensUsed === null || this.metaTokenLimit === null) {
785
- return null;
712
+ if (visible < target) {
713
+ return `${value}${' '.repeat(target - visible)}`;
786
714
  }
787
- const remaining = Math.max(0, this.metaTokenLimit - this.metaTokensUsed);
788
- return this.formatTokenCount(remaining);
715
+ return this.truncateToWidth(value, target);
789
716
  }
790
- formatElapsedLabel(seconds) {
791
- if (seconds < 60) {
792
- return `${seconds}s`;
717
+ truncateToWidth(value, width) {
718
+ const target = Math.max(0, width);
719
+ let visible = 0;
720
+ let result = '';
721
+ for (let i = 0; i < value.length && visible < target; i++) {
722
+ const char = value[i];
723
+ if (char === '\x1b') {
724
+ const match = value.slice(i).match(/^\x1b\[[0-9;]*[A-Za-z]/);
725
+ if (match) {
726
+ result += match[0];
727
+ i += match[0].length - 1;
728
+ continue;
729
+ }
730
+ }
731
+ result += char;
732
+ visible += 1;
793
733
  }
794
- const mins = Math.floor(seconds / 60);
795
- const secs = seconds % 60;
796
- return secs > 0 ? `${mins}m ${secs}s` : `${mins}m`;
734
+ if (value.includes('\x1b') && !result.endsWith(UI_COLORS.reset)) {
735
+ result += UI_COLORS.reset;
736
+ }
737
+ return result;
797
738
  }
798
- formatTokenCount(value) {
799
- if (!Number.isFinite(value)) {
800
- return `${value}`;
739
+ /**
740
+ * Build status bar showing streaming/ready status and key info.
741
+ * This is the TOP line above the input area - minimal Claude Code style.
742
+ */
743
+ buildStatusBar(width) {
744
+ const { green: GREEN, yellow: YELLOW, cyan: CYAN, dim: DIM, reset: R } = UI_COLORS;
745
+ const segments = [];
746
+ // Streaming status with elapsed time
747
+ if (this.mode === 'streaming' || this.streamingLabel) {
748
+ let label = this.streamingLabel || 'Streaming';
749
+ if (this.streamingStartTime) {
750
+ const elapsed = Math.max(0, Math.floor((Date.now() - this.streamingStartTime) / 1000));
751
+ const mins = Math.floor(elapsed / 60);
752
+ const secs = elapsed % 60;
753
+ const elapsedLabel = mins > 0 ? `${mins}m ${secs}s` : `${secs}s`;
754
+ label = `${label} ${elapsedLabel}`;
755
+ }
756
+ segments.push(`${GREEN}● ${label}${R}`);
801
757
  }
802
- if (value >= 1_000_000) {
803
- return `${(value / 1_000_000).toFixed(1)}M`;
758
+ // Override/warning status
759
+ if (this.overrideStatusMessage) {
760
+ segments.push(`${YELLOW}⚠ ${this.overrideStatusMessage}${R}`);
804
761
  }
805
- if (value >= 1_000) {
806
- return `${(value / 1_000).toFixed(1)}k`;
762
+ // Primary status message
763
+ if (this.statusMessage) {
764
+ segments.push(`${CYAN}${this.statusMessage}${R}`);
807
765
  }
808
- return `${Math.round(value)}`;
809
- }
810
- visibleLength(value) {
811
- const ansiPattern = /\u001B\[[0-?]*[ -/]*[@-~]/g;
812
- return value.replace(ansiPattern, '').length;
766
+ // Queue + paste indicators
767
+ if (this.mode === 'streaming' && this.queue.length > 0) {
768
+ segments.push(`${CYAN}queued ${this.queue.length}${R}`);
769
+ }
770
+ if (this.pastePlaceholders.length > 0) {
771
+ const totalLines = this.pastePlaceholders.reduce((sum, p) => sum + p.lineCount, 0);
772
+ segments.push(`${CYAN}📋 ${totalLines}L${R}`);
773
+ }
774
+ // Model info for quick glance
775
+ if (this.modelInfo) {
776
+ segments.push(`${DIM}${this.modelInfo}${R}`);
777
+ }
778
+ // Default hint when idle
779
+ if (!segments.length) {
780
+ segments.push(`${DIM}Type a message or / for commands${R}`);
781
+ }
782
+ // Multi-line indicator
783
+ if (this.buffer.includes('\n')) {
784
+ segments.push(`${DIM}${this.buffer.split('\n').length}L${R}`);
785
+ }
786
+ const joined = segments.join(`${DIM} · ${R}`);
787
+ return this.fitToWidth(joined, width);
813
788
  }
814
789
  /**
815
- * Debug-only snapshot used by tests to assert rendered strings without
816
- * needing a TTY. Not used by production code.
790
+ * Build mode controls line showing toggles and context info.
791
+ * This is the BOTTOM line below the input area - Claude Code style layout with erosolar features.
792
+ *
793
+ * Layout: [toggles on left] ... [context info on right]
817
794
  */
818
- getDebugUiSnapshot(width) {
819
- const cols = Math.max(8, width ?? this.getSize().cols);
820
- return {
821
- meta: this.buildMetaLines(cols - 2),
822
- controls: this.buildModeControls(cols),
823
- };
795
+ buildModeControls(cols) {
796
+ const maxWidth = Math.max(10, cols - 2);
797
+ // Use schema-defined colors for consistency
798
+ const { green: GREEN, yellow: YELLOW, cyan: CYAN, magenta: MAGENTA, red: RED, dim: DIM, reset: R } = UI_COLORS;
799
+ // Mode toggles with colors (following ModeControlsSchema)
800
+ const toggles = [];
801
+ // Edit mode (green=auto, yellow=ask) - per schema.editMode
802
+ if (this.editMode === 'display-edits') {
803
+ toggles.push(`${GREEN}⏵⏵ auto-edit${R}`);
804
+ }
805
+ else {
806
+ toggles.push(`${YELLOW}⏸⏸ ask-first${R}`);
807
+ }
808
+ // Thinking mode (cyan when on) - per schema.thinkingMode
809
+ toggles.push(this.thinkingEnabled ? `${CYAN}💭 think${R}` : `${DIM}○ think${R}`);
810
+ // Verification (green when on) - per schema.verificationMode
811
+ toggles.push(this.verificationEnabled ? `${GREEN}✓ verify${R}` : `${DIM}○ verify${R}`);
812
+ // Auto-continue (magenta when on) - per schema.autoContinueMode
813
+ toggles.push(this.autoContinueEnabled ? `${MAGENTA}↻ auto${R}` : `${DIM}○ auto${R}`);
814
+ const leftPart = toggles.join(`${DIM} · ${R}`) + `${DIM} (⇧⇥)${R}`;
815
+ // Context usage with color - per schema.contextUsage thresholds
816
+ const rightParts = [];
817
+ if (this.contextUsage !== null) {
818
+ const rem = Math.max(0, 100 - this.contextUsage);
819
+ if (rem < 10)
820
+ rightParts.push(`${RED}⚠ ctx: ${rem}%${R}`);
821
+ else if (rem < 25)
822
+ rightParts.push(`${YELLOW}! ctx: ${rem}%${R}`);
823
+ else
824
+ rightParts.push(`${DIM}ctx: ${rem}%${R}`);
825
+ }
826
+ if (this.modelInfo) {
827
+ rightParts.push(`${DIM}${this.modelInfo}${R}`);
828
+ }
829
+ const rightPart = rightParts.join(`${DIM} · ${R}`);
830
+ const leftLen = this.visibleLength(leftPart);
831
+ const rightLen = this.visibleLength(rightPart);
832
+ if (rightPart && leftLen + rightLen < maxWidth - 2) {
833
+ return `${leftPart}${' '.repeat(Math.max(1, maxWidth - leftLen - rightLen))}${rightPart}`;
834
+ }
835
+ const combined = rightPart ? `${leftPart}${DIM} · ${R}${rightPart}` : leftPart;
836
+ return this.fitToWidth(combined, maxWidth);
824
837
  }
825
838
  /**
826
839
  * Force a re-render
@@ -843,213 +856,40 @@ export class TerminalInput extends EventEmitter {
843
856
  handleResize() {
844
857
  this.lastRenderContent = '';
845
858
  this.lastRenderCursor = -1;
846
- this.resetStreamingRenderThrottle();
847
- // If in scrollback mode, re-render the scrollback view with new dimensions
848
- if (this.isInScrollbackMode) {
849
- this.renderScrollbackView();
850
- }
851
- else {
852
- this.scheduleRender();
853
- }
854
- }
855
- /**
856
- * Enter streaming mode with scroll region.
857
- * Sets up terminal scroll region to exclude chat box.
858
- */
859
- enterStreamingScrollRegion() {
860
- const { rows } = this.getSize();
861
- const chatBoxHeight = this.getChatBoxHeight();
862
- const scrollEnd = Math.max(1, rows - chatBoxHeight);
863
- writeLock.lock('enterStreamingScrollRegion');
864
- try {
865
- // Set scroll region for content area (above chat box)
866
- this.write(ESC.SET_SCROLL(1, scrollEnd));
867
- // Position cursor at current content row
868
- this.write(ESC.TO(Math.min(this.contentRow, scrollEnd), 1));
869
- this.scrollRegionActive = true;
870
- this.setStatusMessage('esc to interrupt');
871
- }
872
- finally {
873
- writeLock.unlock();
874
- }
875
- // Render pinned chat box at bottom
876
- this.forceRender();
877
- }
878
- /**
879
- * Exit streaming mode and restore normal operation.
880
- */
881
- exitStreamingScrollRegion() {
882
- writeLock.lock('exitStreamingScrollRegion');
883
- try {
884
- // Reset scroll region to full terminal
885
- this.write(ESC.RESET_SCROLL);
886
- this.scrollRegionActive = false;
887
- this.setStatusMessage('Ready for prompts');
888
- }
889
- finally {
890
- writeLock.unlock();
891
- }
892
- // Final render
893
- this.forceRender();
894
- }
895
- /**
896
- * Render chat box at bottom - now uses unified renderer.
897
- * @deprecated Use renderPinnedChatBox() directly via render()/forceRender()
898
- */
899
- renderChatBoxAtBottom() {
900
- this.renderPinnedChatBox();
901
- }
902
- /**
903
- * Stream content within the scroll region.
904
- * Content is written directly and scrolls naturally.
905
- */
906
- streamContent(content) {
907
- if (!content)
908
- return;
909
- // Capture content in scrollback buffer
910
- this.addToScrollback(content);
911
- writeLock.lock('streamContent');
912
- try {
913
- // Write content - scroll region handles scrolling
914
- this.write(content);
915
- // Track newlines
916
- const newlines = (content.match(/\n/g) || []).length;
917
- this.contentRow += newlines;
918
- }
919
- finally {
920
- writeLock.unlock();
921
- }
922
- // Throttle chat box updates during streaming
923
- this.scheduleStreamingRender(200);
924
- }
925
- /**
926
- * Enable scroll region (no-op in floating mode).
927
- */
928
- enableScrollRegion() {
929
- // No-op: using pure floating approach
930
- }
931
- /**
932
- * Disable scroll region (no-op in floating mode).
933
- */
934
- disableScrollRegion() {
935
- // No-op: using pure floating approach
936
- }
937
- /**
938
- * Calculate chat box height.
939
- */
940
- getChatBoxHeight() {
941
- return 5; // Fixed: divider + input + status + buffer
942
- }
943
- /**
944
- * @deprecated Use streamContent() instead
945
- * Register with display's output interceptor - kept for backwards compatibility
946
- */
947
- registerOutputInterceptor(_display) {
948
- // No-op: Use streamContent() for cleaner floating chat box behavior
949
- }
950
- /**
951
- * Write content above the floating chat box.
952
- * Works both during streaming and when idle.
953
- */
954
- writeToScrollRegion(content) {
955
- if (!content)
956
- return;
957
- // Capture content in scrollback buffer
958
- this.addToScrollback(content);
959
- writeLock.lock('writeToScrollRegion');
960
- try {
961
- // Position cursor at content row and write
962
- this.write(ESC.TO(this.contentRow, 1));
963
- this.write(content);
964
- // Track newlines
965
- const newlines = (content.match(/\n/g) || []).length;
966
- this.contentRow += newlines;
967
- }
968
- finally {
969
- writeLock.unlock();
970
- }
971
- // Re-render chat box below new content (only when not streaming)
972
- if (!this.scrollRegionActive) {
973
- this.forceRender();
974
- }
975
- }
976
- /**
977
- * Enter alternate screen buffer and clear it.
978
- * This gives us full control over the terminal without affecting user's history.
979
- */
980
- enterAlternateScreen() {
981
- writeLock.lock('enterAltScreen');
982
- try {
983
- this.write(ESC.ENTER_ALT_SCREEN);
984
- this.write(ESC.HOME);
985
- this.write(ESC.CLEAR_SCREEN);
986
- this.contentRow = 1;
987
- this.alternateScreenActive = true;
988
- }
989
- finally {
990
- writeLock.unlock();
991
- }
992
- }
993
- /**
994
- * Exit alternate screen buffer.
995
- * Restores the user's previous terminal content.
996
- */
997
- exitAlternateScreen() {
998
- writeLock.lock('exitAltScreen');
999
- try {
1000
- this.write(ESC.EXIT_ALT_SCREEN);
1001
- this.alternateScreenActive = false;
1002
- }
1003
- finally {
1004
- writeLock.unlock();
1005
- }
1006
- }
1007
- /**
1008
- * Check if alternate screen buffer is currently active.
1009
- */
1010
- isAlternateScreenActive() {
1011
- return this.alternateScreenActive;
1012
- }
1013
- /**
1014
- * Get a snapshot of the scrollback buffer (for display on exit).
1015
- */
1016
- getScrollbackSnapshot() {
1017
- return [...this.scrollbackBuffer];
1018
- }
1019
- /**
1020
- * Clear the entire terminal screen and reset content position.
1021
- * This removes all content including the launching command.
1022
- */
1023
- clearScreen() {
1024
- writeLock.lock('clearScreen');
1025
- try {
1026
- this.write(ESC.HOME);
1027
- this.write(ESC.CLEAR_SCREEN);
1028
- this.contentRow = 1;
1029
- }
1030
- finally {
1031
- writeLock.unlock();
1032
- }
1033
- }
1034
- /**
1035
- * Reset content position to row 1.
1036
- * Does NOT clear the terminal - content starts from current position.
1037
- */
1038
- resetContentPosition() {
1039
- this.contentRow = 1;
1040
- }
1041
- /**
1042
- * Set the content row explicitly (used after banner is written).
1043
- * This tells the input where content should start flowing from.
1044
- */
1045
- setContentRow(row) {
1046
- this.contentRow = Math.max(1, row);
859
+ this.scheduleRender();
1047
860
  }
1048
- /**
1049
- * Get the current content row position.
1050
- */
1051
- getContentRow() {
1052
- return this.contentRow;
861
+ // Track current content row for writing
862
+ contentCursorRow = 1;
863
+ /**
864
+ * Register with display's output interceptor.
865
+ * Clears chat box before writes, re-renders after with updated position.
866
+ */
867
+ registerOutputInterceptor(display) {
868
+ if (this.outputInterceptorCleanup) {
869
+ this.outputInterceptorCleanup();
870
+ }
871
+ // Store display reference for streaming timer to use
872
+ this.displayRef = display;
873
+ // Clear chat box before writes to make room for content
874
+ // Re-render is done via periodic timer during streaming, or setContentEndRow after
875
+ this.outputInterceptorCleanup = display.registerOutputInterceptor({
876
+ beforeWrite: () => {
877
+ // Clear chat box to make room for content
878
+ this.clearInputArea();
879
+ },
880
+ afterWrite: () => {
881
+ if (this.mode !== 'streaming') {
882
+ if (this.displayRef?.getTotalWrittenLines) {
883
+ const next = this.displayRef.getTotalWrittenLines();
884
+ if (typeof next === 'number' && Number.isFinite(next)) {
885
+ this.contentEndRow = next;
886
+ }
887
+ }
888
+ this.renderDirty = true;
889
+ queueMicrotask(() => this.render());
890
+ }
891
+ },
892
+ });
1053
893
  }
1054
894
  /**
1055
895
  * Dispose and clean up
@@ -1057,10 +897,24 @@ export class TerminalInput extends EventEmitter {
1057
897
  dispose() {
1058
898
  if (this.disposed)
1059
899
  return;
900
+ // Clean up streaming render timer
901
+ if (this.streamingRenderTimer) {
902
+ clearInterval(this.streamingRenderTimer);
903
+ this.streamingRenderTimer = null;
904
+ }
905
+ // Clean up output interceptor
906
+ if (this.outputInterceptorCleanup) {
907
+ this.outputInterceptorCleanup();
908
+ this.outputInterceptorCleanup = undefined;
909
+ }
910
+ // Reset scroll region
911
+ this.write('\x1b[r');
912
+ // Exit alternate screen buffer (restores main terminal)
913
+ if (this.unifiedUIInitialized) {
914
+ this.write(ESC.ALT_SCREEN_EXIT);
915
+ }
1060
916
  this.disposed = true;
1061
917
  this.enabled = false;
1062
- this.disableScrollRegion();
1063
- this.resetStreamingRenderThrottle();
1064
918
  this.disableBracketedPaste();
1065
919
  this.buffer = '';
1066
920
  this.queue = [];
@@ -1102,13 +956,8 @@ export class TerminalInput extends EventEmitter {
1102
956
  break;
1103
957
  }
1104
958
  }
1105
- /**
1106
- * Handle Alt/Meta key combinations for mode toggles and navigation.
1107
- * All major erosolar-cli features accessible via keyboard shortcuts.
1108
- */
1109
959
  handleMetaKey(key) {
1110
960
  switch (key.name) {
1111
- // === CURSOR MOVEMENT ===
1112
961
  case 'left':
1113
962
  case 'b':
1114
963
  this.moveCursorWordLeft();
@@ -1123,96 +972,14 @@ export class TerminalInput extends EventEmitter {
1123
972
  case 'return':
1124
973
  this.insertNewline();
1125
974
  break;
1126
- // === MODE TOGGLES ===
1127
975
  case 'v':
1128
- // Alt+V: Toggle verification mode (auto-tests after edits)
1129
976
  this.emit('toggleVerify');
1130
977
  break;
1131
978
  case 'c':
1132
- // Alt+C: Toggle auto-continue mode
1133
979
  this.emit('toggleAutoContinue');
1134
980
  break;
1135
- case 't':
1136
- // Alt+T: Toggle/cycle thinking mode
1137
- this.emit('toggleThinking');
1138
- break;
1139
- case 'e':
1140
- // Alt+E: Toggle edit permission mode (ask/auto)
1141
- this.toggleEditMode();
1142
- this.emit('toggleEditMode');
1143
- break;
1144
- case 'x':
1145
- // Alt+X: Clear/compact context
1146
- this.emit('clearContext');
1147
- break;
1148
- // === SCROLLBACK NAVIGATION ===
1149
- case 's':
1150
- // Alt+S: Toggle scrollback mode
1151
- this.toggleScrollbackMode();
1152
- break;
1153
- case 'up':
1154
- // Alt+Up: Quick scroll up into history
1155
- if (!this.isInScrollbackMode) {
1156
- this.scrollUp(10);
1157
- }
1158
- else {
1159
- this.scrollUp(1);
1160
- }
1161
- break;
1162
- case 'down':
1163
- // Alt+Down: Quick scroll down
1164
- if (this.isInScrollbackMode) {
1165
- this.scrollDown(1);
1166
- }
1167
- break;
1168
- case 'pageup':
1169
- // Alt+PageUp: Page up in scrollback
1170
- this.scrollUp(this.getPageSize());
1171
- break;
1172
- case 'pagedown':
1173
- // Alt+PageDown: Page down in scrollback
1174
- this.scrollDown(this.getPageSize());
1175
- break;
1176
- case 'home':
1177
- // Alt+Home: Jump to top of scrollback
1178
- this.scrollToTop();
1179
- break;
1180
- case 'end':
1181
- // Alt+End: Jump to bottom (live)
1182
- this.scrollToBottom();
1183
- break;
1184
981
  }
1185
982
  }
1186
- /**
1187
- * Get page size for scrollback navigation.
1188
- */
1189
- getPageSize() {
1190
- const { rows } = this.getSize();
1191
- const chatBoxHeight = this.getChatBoxHeight();
1192
- return Math.max(5, rows - chatBoxHeight - 2);
1193
- }
1194
- /**
1195
- * Build scroll indicator for the divider line (Claude Code style).
1196
- * Shows scroll position when in scrollback mode, or history size hint when idle.
1197
- */
1198
- buildScrollIndicator() {
1199
- const bufferSize = this.scrollbackBuffer.length;
1200
- // In scrollback mode - show position
1201
- if (this.isInScrollbackMode && this.scrollbackOffset > 0) {
1202
- const { rows } = this.getSize();
1203
- const chatBoxHeight = this.getChatBoxHeight();
1204
- const viewportHeight = Math.max(1, rows - chatBoxHeight);
1205
- const currentPos = Math.max(0, bufferSize - this.scrollbackOffset - viewportHeight);
1206
- const pct = bufferSize > 0 ? Math.round((currentPos / bufferSize) * 100) : 0;
1207
- return `↑${this.scrollbackOffset} · ${pct}% · PgUp/Dn`;
1208
- }
1209
- // Not in scrollback - show hint if there's history
1210
- if (bufferSize > 20) {
1211
- const sizeLabel = bufferSize >= 1000 ? `${Math.floor(bufferSize / 1000)}k` : `${bufferSize}`;
1212
- return `↕${sizeLabel}L · PgUp`;
1213
- }
1214
- return null;
1215
- }
1216
983
  handleSpecialKey(_str, key) {
1217
984
  switch (key.name) {
1218
985
  case 'return':
@@ -1236,53 +1003,38 @@ export class TerminalInput extends EventEmitter {
1236
1003
  this.moveCursorRight();
1237
1004
  return true;
1238
1005
  case 'up':
1239
- // Ctrl+Shift+Up or Shift+Up: Quick scroll up in scrollback
1240
- if ((key.ctrl && key.shift) || key.shift) {
1241
- this.scrollUp(5);
1242
- }
1243
- else {
1244
- this.handleUp();
1245
- }
1006
+ this.handleUp();
1246
1007
  return true;
1247
1008
  case 'down':
1248
- // Ctrl+Shift+Down or Shift+Down: Quick scroll down in scrollback
1249
- if ((key.ctrl && key.shift) || key.shift) {
1250
- this.scrollDown(5);
1251
- }
1252
- else {
1253
- this.handleDown();
1254
- }
1009
+ this.handleDown();
1255
1010
  return true;
1256
1011
  case 'home':
1257
- // Ctrl+Home or in scrollback mode: scroll to top
1258
- if (key.ctrl || this.isInScrollbackMode) {
1259
- this.scrollToTop();
1260
- }
1261
- else {
1262
- this.moveCursorToLineStart();
1263
- }
1012
+ this.moveCursorToLineStart();
1264
1013
  return true;
1265
1014
  case 'end':
1266
- // Ctrl+End or in scrollback mode: scroll to bottom (live mode)
1267
- if (key.ctrl || this.isInScrollbackMode) {
1268
- this.scrollToBottom();
1269
- }
1270
- else {
1271
- this.moveCursorToLineEnd();
1272
- }
1273
- return true;
1274
- case 'pageup':
1275
- this.scrollUp(20); // Scroll up by 20 lines
1276
- return true;
1277
- case 'pagedown':
1278
- this.scrollDown(20); // Scroll down by 20 lines
1015
+ this.moveCursorToLineEnd();
1279
1016
  return true;
1280
1017
  case 'tab':
1281
1018
  if (key.shift) {
1282
1019
  this.toggleEditMode();
1283
1020
  return true;
1284
1021
  }
1285
- this.insertText(' ');
1022
+ // Tab: toggle paste expansion if in placeholder, otherwise toggle thinking
1023
+ if (this.findPlaceholderAt(this.cursor)) {
1024
+ this.togglePasteExpansion();
1025
+ }
1026
+ else {
1027
+ this.toggleThinking();
1028
+ }
1029
+ return true;
1030
+ case 'escape':
1031
+ // Esc: interrupt if streaming, otherwise clear buffer
1032
+ if (this.mode === 'streaming') {
1033
+ this.emit('interrupt');
1034
+ }
1035
+ else if (this.buffer.length > 0) {
1036
+ this.clear();
1037
+ }
1286
1038
  return true;
1287
1039
  }
1288
1040
  return false;
@@ -1300,6 +1052,7 @@ export class TerminalInput extends EventEmitter {
1300
1052
  this.insertPlainText(chunk, insertPos);
1301
1053
  this.cursor = insertPos + chunk.length;
1302
1054
  this.emit('change', this.buffer);
1055
+ this.updateSuggestions();
1303
1056
  this.scheduleRender();
1304
1057
  }
1305
1058
  insertNewline() {
@@ -1324,6 +1077,7 @@ export class TerminalInput extends EventEmitter {
1324
1077
  this.cursor = Math.max(0, this.cursor - 1);
1325
1078
  }
1326
1079
  this.emit('change', this.buffer);
1080
+ this.updateSuggestions();
1327
1081
  this.scheduleRender();
1328
1082
  }
1329
1083
  deleteForward() {
@@ -1551,12 +1305,13 @@ export class TerminalInput extends EventEmitter {
1551
1305
  timestamp: Date.now(),
1552
1306
  });
1553
1307
  this.emit('queue', text);
1554
- this.clear(); // Clear immediately for queued input
1308
+ this.clear(); // Clear immediately for queued input, re-render to update queue display
1555
1309
  }
1556
1310
  else {
1557
- // In idle mode, clear the input first, then emit submit.
1558
- // The prompt will be logged as a visible message by the caller.
1559
- this.clear();
1311
+ // In idle mode, clear the input WITHOUT rendering.
1312
+ // The caller will display the user message and start streaming.
1313
+ // We'll render the input area again after streaming ends.
1314
+ this.clear(true); // Skip render - streaming will handle display
1560
1315
  this.emit('submit', text);
1561
1316
  }
1562
1317
  }
@@ -1573,9 +1328,7 @@ export class TerminalInput extends EventEmitter {
1573
1328
  if (available <= 0)
1574
1329
  return;
1575
1330
  const chunk = clean.slice(0, available);
1576
- const isMultiline = isMultilinePaste(chunk);
1577
- const isShortMultiline = isMultiline && this.shouldInlineMultiline(chunk);
1578
- if (isMultiline && !isShortMultiline) {
1331
+ if (isMultilinePaste(chunk)) {
1579
1332
  this.insertPastePlaceholder(chunk);
1580
1333
  }
1581
1334
  else {
@@ -1639,236 +1392,6 @@ export class TerminalInput extends EventEmitter {
1639
1392
  return { lines, cursorLine, cursorCol };
1640
1393
  }
1641
1394
  // ===========================================================================
1642
- // SCROLLBACK BUFFER
1643
- // ===========================================================================
1644
- /**
1645
- * Add content to the scrollback buffer for history retention
1646
- */
1647
- addToScrollback(content) {
1648
- if (!content)
1649
- return;
1650
- // Split content into lines and add to buffer
1651
- const lines = content.split('\n');
1652
- for (let i = 0; i < lines.length; i++) {
1653
- const line = lines[i];
1654
- if (line !== undefined) {
1655
- // Only add non-empty lines or preserve newlines between content
1656
- if (i < lines.length - 1 || line.length > 0) {
1657
- this.scrollbackBuffer.push(line);
1658
- }
1659
- }
1660
- }
1661
- // Trim buffer if it exceeds max size
1662
- while (this.scrollbackBuffer.length > this.maxScrollbackLines) {
1663
- this.scrollbackBuffer.shift();
1664
- }
1665
- // If we're in live mode (not scrolled up), keep offset at 0
1666
- if (this.scrollbackOffset === 0) {
1667
- this.isInScrollbackMode = false;
1668
- }
1669
- }
1670
- /**
1671
- * Scroll up by a number of lines (PageUp)
1672
- */
1673
- scrollUp(lines = 10) {
1674
- const { rows } = this.getSize();
1675
- const chatBoxHeight = this.getChatBoxHeight();
1676
- const visibleLines = Math.max(1, rows - chatBoxHeight);
1677
- // Calculate max scroll offset
1678
- const maxOffset = Math.max(0, this.scrollbackBuffer.length - visibleLines);
1679
- this.scrollbackOffset = Math.min(this.scrollbackOffset + lines, maxOffset);
1680
- this.isInScrollbackMode = this.scrollbackOffset > 0;
1681
- if (this.isInScrollbackMode) {
1682
- this.renderScrollbackView();
1683
- }
1684
- }
1685
- /**
1686
- * Scroll down by a number of lines (PageDown)
1687
- */
1688
- scrollDown(lines = 10) {
1689
- this.scrollbackOffset = Math.max(0, this.scrollbackOffset - lines);
1690
- this.isInScrollbackMode = this.scrollbackOffset > 0;
1691
- if (this.isInScrollbackMode) {
1692
- this.renderScrollbackView();
1693
- }
1694
- else {
1695
- // Returned to live mode - force re-render
1696
- this.forceRender();
1697
- }
1698
- }
1699
- /**
1700
- * Jump to the top of scrollback buffer
1701
- */
1702
- scrollToTop() {
1703
- const { rows } = this.getSize();
1704
- const chatBoxHeight = this.getChatBoxHeight();
1705
- const visibleLines = Math.max(1, rows - chatBoxHeight);
1706
- const maxOffset = Math.max(0, this.scrollbackBuffer.length - visibleLines);
1707
- this.scrollbackOffset = maxOffset;
1708
- this.isInScrollbackMode = true;
1709
- this.renderScrollbackView();
1710
- }
1711
- /**
1712
- * Jump to the bottom (live mode)
1713
- */
1714
- scrollToBottom() {
1715
- this.scrollbackOffset = 0;
1716
- this.isInScrollbackMode = false;
1717
- this.forceRender();
1718
- }
1719
- /**
1720
- * Toggle scrollback mode on/off (Alt+S hotkey)
1721
- */
1722
- toggleScrollbackMode() {
1723
- if (this.isInScrollbackMode) {
1724
- this.scrollToBottom();
1725
- }
1726
- else if (this.scrollbackBuffer.length > 0) {
1727
- this.scrollUp(20);
1728
- }
1729
- }
1730
- /**
1731
- * Render the scrollback buffer view with enhanced visuals
1732
- * Features:
1733
- * - Visual scroll position indicator
1734
- * - Progress bar showing position in history
1735
- * - Keyboard navigation hints
1736
- * - Animated indicators
1737
- */
1738
- renderScrollbackView() {
1739
- const { rows, cols } = this.getSize();
1740
- const chatBoxHeight = this.getChatBoxHeight();
1741
- const contentHeight = Math.max(1, rows - chatBoxHeight);
1742
- writeLock.lock('renderScrollback');
1743
- try {
1744
- this.write(ESC.SAVE);
1745
- this.write(ESC.HIDE);
1746
- // Clear content area
1747
- for (let i = 1; i <= contentHeight; i++) {
1748
- this.write(ESC.TO(i, 1));
1749
- this.write(ESC.CLEAR_LINE);
1750
- }
1751
- // Calculate which lines to show
1752
- const totalLines = this.scrollbackBuffer.length;
1753
- const startIdx = Math.max(0, totalLines - this.scrollbackOffset - contentHeight);
1754
- const endIdx = Math.max(0, totalLines - this.scrollbackOffset);
1755
- const visibleLines = this.scrollbackBuffer.slice(startIdx, endIdx);
1756
- // Build header bar with navigation hints
1757
- const headerInfo = this.buildScrollbackHeader(cols, totalLines, startIdx, endIdx);
1758
- this.write(ESC.TO(1, 1));
1759
- this.write(headerInfo);
1760
- // Render visible lines with line numbers and visual guides
1761
- const lineNumWidth = String(totalLines).length + 1;
1762
- const contentStart = 2; // Start after header
1763
- for (let i = 0; i < Math.min(visibleLines.length, contentHeight - 1); i++) {
1764
- const line = visibleLines[i] ?? '';
1765
- const lineNum = startIdx + i + 1;
1766
- this.write(ESC.TO(contentStart + i, 1));
1767
- // Line number gutter
1768
- const numStr = String(lineNum).padStart(lineNumWidth, ' ');
1769
- this.write(theme.ui.muted(`${numStr} │ `));
1770
- // Content with truncation
1771
- const gutterWidth = lineNumWidth + 4;
1772
- const maxLen = cols - gutterWidth - 2;
1773
- const displayLine = line.length > maxLen ? line.slice(0, maxLen - 3) + '...' : line;
1774
- this.write(displayLine);
1775
- }
1776
- // Add visual scroll track on the right edge
1777
- this.renderScrollTrack(cols, contentHeight, totalLines, startIdx, endIdx);
1778
- this.write(ESC.RESTORE);
1779
- this.write(ESC.SHOW);
1780
- }
1781
- finally {
1782
- writeLock.unlock();
1783
- }
1784
- // Re-render chat box
1785
- this.forceRender();
1786
- }
1787
- /**
1788
- * Build scrollback header with navigation hints
1789
- */
1790
- buildScrollbackHeader(cols, totalLines, startIdx, endIdx) {
1791
- const percentage = Math.round((endIdx / totalLines) * 100);
1792
- // Animated scroll indicator
1793
- const scrollFrames = ['◆', '◇', '◆', '◈'];
1794
- this.scrollIndicatorFrame = (this.scrollIndicatorFrame + 1) % scrollFrames.length;
1795
- const indicator = scrollFrames[this.scrollIndicatorFrame];
1796
- // Build header parts
1797
- const leftPart = theme.info(`${indicator} SCROLLBACK`) +
1798
- theme.ui.muted(` [${startIdx + 1}-${endIdx} of ${totalLines}]`);
1799
- const progressBar = this.buildProgressBar(percentage, 15);
1800
- const rightPart = progressBar +
1801
- theme.ui.muted(` ${percentage}%`) +
1802
- theme.ui.muted(' │ ') +
1803
- theme.primary('PgUp') + theme.ui.muted('/') + theme.primary('PgDn') +
1804
- theme.ui.muted(' scroll · ') +
1805
- theme.primary('End') + theme.ui.muted(' exit');
1806
- const leftLen = this.visibleLength(leftPart);
1807
- const rightLen = this.visibleLength(rightPart);
1808
- const padding = Math.max(1, cols - leftLen - rightLen - 2);
1809
- return `${leftPart}${' '.repeat(padding)}${rightPart}`;
1810
- }
1811
- /**
1812
- * Render visual scroll track on the right side
1813
- */
1814
- renderScrollTrack(cols, contentHeight, totalLines, startIdx, endIdx) {
1815
- if (totalLines <= contentHeight || cols < 40)
1816
- return;
1817
- const trackHeight = contentHeight - 1; // Exclude header
1818
- const viewportRatio = (endIdx - startIdx) / totalLines;
1819
- const positionRatio = startIdx / Math.max(1, totalLines - (endIdx - startIdx));
1820
- // Calculate thumb size and position
1821
- const thumbSize = Math.max(1, Math.round(viewportRatio * trackHeight));
1822
- const thumbStart = Math.round(positionRatio * (trackHeight - thumbSize));
1823
- // Render track on right edge
1824
- for (let i = 0; i < trackHeight; i++) {
1825
- const row = 2 + i; // Start after header
1826
- this.write(ESC.TO(row, cols));
1827
- if (i >= thumbStart && i < thumbStart + thumbSize) {
1828
- // Thumb (viewport indicator)
1829
- this.write(theme.accent('█'));
1830
- }
1831
- else {
1832
- // Track background
1833
- this.write(theme.ui.muted('░'));
1834
- }
1835
- }
1836
- }
1837
- /**
1838
- * Build a visual progress bar
1839
- */
1840
- buildProgressBar(percentage, width = 10) {
1841
- const filled = Math.round((percentage / 100) * width);
1842
- const empty = width - filled;
1843
- const bar = theme.accent('█'.repeat(filled)) +
1844
- theme.ui.muted('░'.repeat(empty));
1845
- return `${theme.ui.muted('[')}${bar}${theme.ui.muted(']')}`;
1846
- }
1847
- /**
1848
- * Get scrollback buffer content (for persistence)
1849
- */
1850
- getScrollbackBuffer() {
1851
- return [...this.scrollbackBuffer];
1852
- }
1853
- /**
1854
- * Load scrollback buffer (for restoration)
1855
- */
1856
- loadScrollbackBuffer(lines) {
1857
- this.scrollbackBuffer = [...lines];
1858
- // Trim if necessary
1859
- while (this.scrollbackBuffer.length > this.maxScrollbackLines) {
1860
- this.scrollbackBuffer.shift();
1861
- }
1862
- }
1863
- /**
1864
- * Clear scrollback buffer
1865
- */
1866
- clearScrollbackBuffer() {
1867
- this.scrollbackBuffer = [];
1868
- this.scrollbackOffset = 0;
1869
- this.isInScrollbackMode = false;
1870
- }
1871
- // ===========================================================================
1872
1395
  // UTILITIES
1873
1396
  // ===========================================================================
1874
1397
  getComposedLength() {
@@ -1941,19 +1464,17 @@ export class TerminalInput extends EventEmitter {
1941
1464
  this.shiftPlaceholders(position, text.length);
1942
1465
  this.buffer = this.buffer.slice(0, position) + text + this.buffer.slice(position);
1943
1466
  }
1944
- shouldInlineMultiline(content) {
1945
- const lines = content.split('\n').length;
1946
- const maxInlineLines = 4;
1947
- const maxInlineChars = 240;
1948
- return lines <= maxInlineLines && content.length <= maxInlineChars;
1949
- }
1950
1467
  findPlaceholderAt(position) {
1951
1468
  return this.pastePlaceholders.find((ph) => position >= ph.start && position < ph.end) ?? null;
1952
1469
  }
1953
- buildPlaceholder(lineCount) {
1470
+ buildPlaceholder(summary) {
1954
1471
  const id = ++this.pasteCounter;
1955
- const plural = lineCount === 1 ? '' : 's';
1956
- const placeholder = `[Pasted text #${id} +${lineCount} line${plural}]`;
1472
+ const lang = summary.language ? ` ${summary.language.toUpperCase()}` : '';
1473
+ // Show first line preview (truncated)
1474
+ const preview = summary.preview.length > 30
1475
+ ? `${summary.preview.slice(0, 30)}...`
1476
+ : summary.preview;
1477
+ const placeholder = `[📋 #${id}${lang} ${summary.lineCount}L] "${preview}"`;
1957
1478
  return { id, placeholder };
1958
1479
  }
1959
1480
  insertPastePlaceholder(content) {
@@ -1961,21 +1482,67 @@ export class TerminalInput extends EventEmitter {
1961
1482
  if (available <= 0)
1962
1483
  return;
1963
1484
  const cleanContent = content.slice(0, available);
1964
- const lineCount = cleanContent.split('\n').length;
1965
- const { id, placeholder } = this.buildPlaceholder(lineCount);
1485
+ const summary = generatePasteSummary(cleanContent);
1486
+ // For short pastes (< 5 lines), show full content instead of placeholder
1487
+ if (summary.lineCount < 5) {
1488
+ const placeholder = this.findPlaceholderAt(this.cursor);
1489
+ const insertPos = placeholder && this.cursor > placeholder.start ? placeholder.end : this.cursor;
1490
+ this.insertPlainText(cleanContent, insertPos);
1491
+ this.cursor = insertPos + cleanContent.length;
1492
+ return;
1493
+ }
1494
+ const { id, placeholder } = this.buildPlaceholder(summary);
1966
1495
  const insertPos = this.cursor;
1967
1496
  this.shiftPlaceholders(insertPos, placeholder.length);
1968
1497
  this.pastePlaceholders.push({
1969
1498
  id,
1970
1499
  content: cleanContent,
1971
- lineCount,
1500
+ lineCount: summary.lineCount,
1972
1501
  placeholder,
1973
1502
  start: insertPos,
1974
1503
  end: insertPos + placeholder.length,
1504
+ summary,
1505
+ expanded: false,
1975
1506
  });
1976
1507
  this.buffer = this.buffer.slice(0, insertPos) + placeholder + this.buffer.slice(insertPos);
1977
1508
  this.cursor = insertPos + placeholder.length;
1978
1509
  }
1510
+ /**
1511
+ * Toggle expansion of a paste placeholder at the current cursor position.
1512
+ * When expanded, shows first 3 and last 2 lines of the content.
1513
+ */
1514
+ togglePasteExpansion() {
1515
+ const placeholder = this.findPlaceholderAt(this.cursor);
1516
+ if (!placeholder)
1517
+ return false;
1518
+ placeholder.expanded = !placeholder.expanded;
1519
+ // Update the placeholder text in buffer
1520
+ const newPlaceholder = placeholder.expanded
1521
+ ? this.buildExpandedPlaceholder(placeholder)
1522
+ : this.buildPlaceholder(placeholder.summary).placeholder;
1523
+ const lengthDiff = newPlaceholder.length - placeholder.placeholder.length;
1524
+ // Update buffer
1525
+ this.buffer =
1526
+ this.buffer.slice(0, placeholder.start) +
1527
+ newPlaceholder +
1528
+ this.buffer.slice(placeholder.end);
1529
+ // Update placeholder tracking
1530
+ placeholder.placeholder = newPlaceholder;
1531
+ placeholder.end = placeholder.start + newPlaceholder.length;
1532
+ // Shift other placeholders
1533
+ this.shiftPlaceholders(placeholder.end, lengthDiff, placeholder.id);
1534
+ this.scheduleRender();
1535
+ return true;
1536
+ }
1537
+ buildExpandedPlaceholder(ph) {
1538
+ const lines = ph.content.split('\n');
1539
+ const firstLines = lines.slice(0, 3).map(l => l.slice(0, 60)).join('\n');
1540
+ const lastLines = lines.length > 5
1541
+ ? '\n...\n' + lines.slice(-2).map(l => l.slice(0, 60)).join('\n')
1542
+ : '';
1543
+ const lang = ph.summary.language ? ` ${ph.summary.language.toUpperCase()}` : '';
1544
+ return `[📋 #${ph.id}${lang} ${ph.lineCount}L ▼]\n${firstLines}${lastLines}\n[/📋 #${ph.id}]`;
1545
+ }
1979
1546
  deletePlaceholder(placeholder) {
1980
1547
  const length = placeholder.end - placeholder.start;
1981
1548
  this.buffer = this.buffer.slice(0, placeholder.start) + this.buffer.slice(placeholder.end);
@@ -1983,11 +1550,7 @@ export class TerminalInput extends EventEmitter {
1983
1550
  this.shiftPlaceholders(placeholder.end, -length, placeholder.id);
1984
1551
  this.cursor = placeholder.start;
1985
1552
  }
1986
- updateContextUsage(value, autoCompactThreshold) {
1987
- if (typeof autoCompactThreshold === 'number' && Number.isFinite(autoCompactThreshold)) {
1988
- const boundedThreshold = Math.max(1, Math.min(100, Math.round(autoCompactThreshold)));
1989
- this.contextAutoCompactThreshold = boundedThreshold;
1990
- }
1553
+ updateContextUsage(value) {
1991
1554
  if (value === null || !Number.isFinite(value)) {
1992
1555
  this.contextUsage = null;
1993
1556
  }
@@ -2014,28 +1577,6 @@ export class TerminalInput extends EventEmitter {
2014
1577
  const next = this.editMode === 'display-edits' ? 'ask-permission' : 'display-edits';
2015
1578
  this.setEditMode(next);
2016
1579
  }
2017
- scheduleStreamingRender(delayMs) {
2018
- if (this.streamingRenderTimer)
2019
- return;
2020
- const wait = Math.max(16, delayMs);
2021
- this.streamingRenderTimer = setTimeout(() => {
2022
- this.streamingRenderTimer = null;
2023
- // During streaming, only update chat box (not full render)
2024
- if (this.scrollRegionActive) {
2025
- this.renderChatBoxAtBottom();
2026
- }
2027
- else {
2028
- this.render();
2029
- }
2030
- }, wait);
2031
- }
2032
- resetStreamingRenderThrottle() {
2033
- if (this.streamingRenderTimer) {
2034
- clearTimeout(this.streamingRenderTimer);
2035
- this.streamingRenderTimer = null;
2036
- }
2037
- this.lastStreamingRender = 0;
2038
- }
2039
1580
  scheduleRender() {
2040
1581
  if (!this.canRender())
2041
1582
  return;