erosolar-cli 1.7.355 → 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 (330) 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 -418
  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 +124 -258
  258. package/dist/shell/terminalInput.d.ts.map +1 -1
  259. package/dist/shell/terminalInput.js +608 -1010
  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 -140
  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 +47 -180
  268. package/dist/subagents/taskRunner.js.map +1 -1
  269. package/dist/tools/learnTools.js +4 -127
  270. package/dist/tools/learnTools.js.map +1 -1
  271. package/dist/tools/securityTools.d.ts +22 -0
  272. package/dist/tools/securityTools.d.ts.map +1 -0
  273. package/dist/tools/securityTools.js +448 -0
  274. package/dist/tools/securityTools.js.map +1 -0
  275. package/dist/ui/ShellUIAdapter.d.ts +1 -7
  276. package/dist/ui/ShellUIAdapter.d.ts.map +1 -1
  277. package/dist/ui/ShellUIAdapter.js +18 -42
  278. package/dist/ui/ShellUIAdapter.js.map +1 -1
  279. package/dist/ui/display.d.ts +45 -24
  280. package/dist/ui/display.d.ts.map +1 -1
  281. package/dist/ui/display.js +274 -148
  282. package/dist/ui/display.js.map +1 -1
  283. package/dist/ui/persistentPrompt.d.ts +50 -0
  284. package/dist/ui/persistentPrompt.d.ts.map +1 -0
  285. package/dist/ui/persistentPrompt.js +92 -0
  286. package/dist/ui/persistentPrompt.js.map +1 -0
  287. package/dist/ui/terminalUISchema.d.ts +195 -0
  288. package/dist/ui/terminalUISchema.d.ts.map +1 -0
  289. package/dist/ui/terminalUISchema.js +113 -0
  290. package/dist/ui/terminalUISchema.js.map +1 -0
  291. package/dist/ui/theme.d.ts.map +1 -1
  292. package/dist/ui/theme.js +8 -6
  293. package/dist/ui/theme.js.map +1 -1
  294. package/dist/ui/toolDisplay.d.ts +158 -0
  295. package/dist/ui/toolDisplay.d.ts.map +1 -1
  296. package/dist/ui/toolDisplay.js +348 -0
  297. package/dist/ui/toolDisplay.js.map +1 -1
  298. package/dist/ui/unified/layout.d.ts +0 -20
  299. package/dist/ui/unified/layout.d.ts.map +1 -1
  300. package/dist/ui/unified/layout.js +216 -105
  301. package/dist/ui/unified/layout.js.map +1 -1
  302. package/package.json +4 -4
  303. package/scripts/deploy-security-capabilities.js +178 -0
  304. package/dist/core/hooks.d.ts +0 -113
  305. package/dist/core/hooks.d.ts.map +0 -1
  306. package/dist/core/hooks.js +0 -267
  307. package/dist/core/hooks.js.map +0 -1
  308. package/dist/core/metricsTracker.d.ts +0 -122
  309. package/dist/core/metricsTracker.d.ts.map +0 -1
  310. package/dist/core/metricsTracker.js.map +0 -1
  311. package/dist/core/securityAssessment.d.ts +0 -91
  312. package/dist/core/securityAssessment.d.ts.map +0 -1
  313. package/dist/core/securityAssessment.js +0 -580
  314. package/dist/core/securityAssessment.js.map +0 -1
  315. package/dist/core/verification.d.ts +0 -137
  316. package/dist/core/verification.d.ts.map +0 -1
  317. package/dist/core/verification.js +0 -323
  318. package/dist/core/verification.js.map +0 -1
  319. package/dist/subagents/agentConfig.d.ts +0 -27
  320. package/dist/subagents/agentConfig.d.ts.map +0 -1
  321. package/dist/subagents/agentConfig.js +0 -89
  322. package/dist/subagents/agentConfig.js.map +0 -1
  323. package/dist/subagents/agentRegistry.d.ts +0 -33
  324. package/dist/subagents/agentRegistry.d.ts.map +0 -1
  325. package/dist/subagents/agentRegistry.js +0 -162
  326. package/dist/subagents/agentRegistry.js.map +0 -1
  327. package/dist/utils/frontmatter.d.ts +0 -10
  328. package/dist/utils/frontmatter.d.ts.map +0 -1
  329. package/dist/utils/frontmatter.js +0 -78
  330. 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,39 +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 and mouse events)
136
+ * Process raw terminal data (handles bracketed paste sequences)
177
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
140
  // Check for paste start
194
141
  if (data.includes(ESC.PASTE_START)) {
195
142
  this.isPasting = true;
@@ -220,21 +167,6 @@ export class TerminalInput extends EventEmitter {
220
167
  }
221
168
  return { consumed: false, passthrough: data };
222
169
  }
223
- /**
224
- * Handle mouse events (button, x, y coordinates, action)
225
- */
226
- handleMouseEvent(button, _x, _y, _action) {
227
- // Mouse wheel events: button 64 = scroll up, button 65 = scroll down
228
- if (button === 64) {
229
- // Scroll up (3 lines per wheel tick)
230
- this.scrollUp(3);
231
- }
232
- else if (button === 65) {
233
- // Scroll down (3 lines per wheel tick)
234
- this.scrollDown(3);
235
- }
236
- // Ignore other mouse events (clicks, drags, etc.) for now
237
- }
238
170
  /**
239
171
  * Handle a keypress event
240
172
  */
@@ -257,36 +189,258 @@ export class TerminalInput extends EventEmitter {
257
189
  if (handled)
258
190
  return;
259
191
  }
192
+ // Handle '?' for help hint (if buffer is empty)
193
+ if (str === '?' && this.buffer.length === 0) {
194
+ this.emit('showHelp');
195
+ return;
196
+ }
260
197
  // Insert printable characters
261
198
  if (str && !key?.ctrl && !key?.meta) {
262
199
  this.insertText(str);
263
200
  }
264
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
+ }
265
297
  /**
266
298
  * Set the input mode
267
299
  *
268
- * 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.
269
302
  */
270
303
  setMode(mode) {
271
304
  const prevMode = this.mode;
272
305
  this.mode = mode;
273
306
  if (mode === 'streaming' && prevMode !== 'streaming') {
274
- 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
275
332
  this.renderDirty = true;
276
- this.render();
333
+ this.scheduleRender();
277
334
  }
278
335
  else if (mode !== 'streaming' && prevMode === 'streaming') {
279
- // Streaming ended - render the input area
280
- this.resetStreamingRenderThrottle();
281
- 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();
282
346
  }
283
347
  }
284
348
  /**
285
- * Legacy method - no longer used (content flows naturally).
286
- * @deprecated Use setContentRow instead
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);
384
+ }
385
+ }
386
+ /**
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.
287
410
  */
288
- setPinnedHeaderLines(_count) {
289
- // No-op: scroll region pinning removed
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
441
+ */
442
+ isThinkingEnabled() {
443
+ return this.thinkingEnabled;
290
444
  }
291
445
  /**
292
446
  * Get current mode
@@ -319,14 +473,17 @@ export class TerminalInput extends EventEmitter {
319
473
  }
320
474
  /**
321
475
  * Clear the buffer
476
+ * @param skipRender - If true, don't trigger a re-render (used during submit flow)
322
477
  */
323
- clear() {
478
+ clear(skipRender = false) {
324
479
  this.buffer = '';
325
480
  this.cursor = 0;
326
481
  this.historyIndex = -1;
327
482
  this.tempInput = '';
328
483
  this.pastePlaceholders = [];
329
- this.scheduleRender();
484
+ if (!skipRender) {
485
+ this.scheduleRender();
486
+ }
330
487
  }
331
488
  /**
332
489
  * Get queued inputs
@@ -397,37 +554,6 @@ export class TerminalInput extends EventEmitter {
397
554
  this.streamingLabel = next;
398
555
  this.scheduleRender();
399
556
  }
400
- /**
401
- * Surface meta status just above the divider (e.g., elapsed time or token usage).
402
- */
403
- setMetaStatus(meta) {
404
- const nextElapsed = typeof meta.elapsedSeconds === 'number' && Number.isFinite(meta.elapsedSeconds) && meta.elapsedSeconds >= 0
405
- ? Math.floor(meta.elapsedSeconds)
406
- : null;
407
- const nextTokens = typeof meta.tokensUsed === 'number' && Number.isFinite(meta.tokensUsed) && meta.tokensUsed >= 0
408
- ? Math.floor(meta.tokensUsed)
409
- : null;
410
- const nextLimit = typeof meta.tokenLimit === 'number' && Number.isFinite(meta.tokenLimit) && meta.tokenLimit > 0
411
- ? Math.floor(meta.tokenLimit)
412
- : null;
413
- const nextThinking = typeof meta.thinkingMs === 'number' && Number.isFinite(meta.thinkingMs) && meta.thinkingMs >= 0
414
- ? Math.floor(meta.thinkingMs)
415
- : null;
416
- const nextThinkingHasContent = !!meta.thinkingHasContent;
417
- if (this.metaElapsedSeconds === nextElapsed &&
418
- this.metaTokensUsed === nextTokens &&
419
- this.metaTokenLimit === nextLimit &&
420
- this.metaThinkingMs === nextThinking &&
421
- this.metaThinkingHasContent === nextThinkingHasContent) {
422
- return;
423
- }
424
- this.metaElapsedSeconds = nextElapsed;
425
- this.metaTokensUsed = nextTokens;
426
- this.metaTokenLimit = nextLimit;
427
- this.metaThinkingMs = nextThinking;
428
- this.metaThinkingHasContent = nextThinkingHasContent;
429
- this.scheduleRender();
430
- }
431
557
  /**
432
558
  * Keep mode toggles (verification/auto-continue) visible in the control bar.
433
559
  * Hotkey labels remain stable so the bar looks the same before/during streaming.
@@ -437,22 +563,26 @@ export class TerminalInput extends EventEmitter {
437
563
  const nextAutoContinue = !!options.autoContinueEnabled;
438
564
  const nextVerifyHotkey = options.verificationHotkey ?? this.verificationHotkey;
439
565
  const nextAutoHotkey = options.autoContinueHotkey ?? this.autoContinueHotkey;
440
- const nextThinkingHotkey = options.thinkingHotkey ?? this.thinkingHotkey;
441
- const nextThinkingLabel = options.thinkingModeLabel === undefined ? this.thinkingModeLabel : (options.thinkingModeLabel || null);
442
566
  if (this.verificationEnabled === nextVerification &&
443
567
  this.autoContinueEnabled === nextAutoContinue &&
444
568
  this.verificationHotkey === nextVerifyHotkey &&
445
- this.autoContinueHotkey === nextAutoHotkey &&
446
- this.thinkingHotkey === nextThinkingHotkey &&
447
- this.thinkingModeLabel === nextThinkingLabel) {
569
+ this.autoContinueHotkey === nextAutoHotkey) {
448
570
  return;
449
571
  }
450
572
  this.verificationEnabled = nextVerification;
451
573
  this.autoContinueEnabled = nextAutoContinue;
452
574
  this.verificationHotkey = nextVerifyHotkey;
453
575
  this.autoContinueHotkey = nextAutoHotkey;
454
- this.thinkingHotkey = nextThinkingHotkey;
455
- 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;
456
586
  this.scheduleRender();
457
587
  }
458
588
  /**
@@ -465,174 +595,33 @@ export class TerminalInput extends EventEmitter {
465
595
  this.scheduleRender();
466
596
  }
467
597
  /**
468
- * Surface model/provider context in the controls bar.
469
- */
470
- setModelContext(options) {
471
- const nextModel = options.model?.trim() || null;
472
- const nextProvider = options.provider?.trim() || null;
473
- if (this.modelLabel === nextModel && this.providerLabel === nextProvider) {
474
- return;
475
- }
476
- this.modelLabel = nextModel;
477
- this.providerLabel = nextProvider;
478
- this.scheduleRender();
479
- }
480
- /**
481
- * Render the floating input area at contentRow.
482
- *
483
- * The chat box "floats" - it renders right below the last streamed content.
484
- * As content is added, contentRow advances, and the chat box moves down.
485
- * 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
486
601
  */
487
602
  render() {
488
603
  if (!this.canRender())
489
604
  return;
490
605
  if (this.isRendering)
491
606
  return;
492
- const streamingActive = this.mode === 'streaming' || isStreamingMode();
493
- // During streaming, throttle re-renders
494
- if (streamingActive && this.lastStreamingRender > 0) {
495
- const elapsed = Date.now() - this.lastStreamingRender;
496
- const waitMs = Math.max(0, this.streamingRenderInterval - elapsed);
497
- if (waitMs > 0) {
498
- this.renderDirty = true;
499
- this.scheduleStreamingRender(waitMs);
500
- return;
501
- }
502
- }
503
607
  const shouldSkip = !this.renderDirty &&
504
608
  this.buffer === this.lastRenderContent &&
505
609
  this.cursor === this.lastRenderCursor;
506
610
  this.renderDirty = false;
611
+ // Skip if nothing changed (unless explicitly forced)
507
612
  if (shouldSkip) {
508
613
  return;
509
614
  }
615
+ // If write lock is held, defer render
510
616
  if (writeLock.isLocked()) {
511
617
  writeLock.safeWrite(() => this.render());
512
618
  return;
513
619
  }
514
- this.renderPinnedChatBox();
515
- }
516
- /**
517
- * Unified scroll region renderer.
518
- * Chat box is ALWAYS pinned at the bottom of the terminal.
519
- * Content scrolls in the region above the chat box.
520
- */
521
- renderPinnedChatBox() {
522
- const { rows, cols } = this.getSize();
523
- const maxWidth = Math.max(8, cols - 4);
524
- const streamingActive = this.mode === 'streaming' || isStreamingMode();
525
- // Wrap buffer into display lines
526
- const { lines, cursorLine, cursorCol } = this.wrapBuffer(maxWidth);
527
- const maxVisible = Math.max(1, Math.min(this.config.maxLines, rows - 3));
528
- const displayLines = Math.min(lines.length, maxVisible);
529
- const metaLines = this.buildMetaLines(cols - 2);
530
- // Calculate display window (keep cursor visible)
531
- let startLine = 0;
532
- if (lines.length > displayLines) {
533
- startLine = Math.max(0, cursorLine - displayLines + 1);
534
- startLine = Math.min(startLine, lines.length - displayLines);
535
- }
536
- const visibleLines = lines.slice(startLine, startLine + displayLines);
537
- const adjustedCursorLine = cursorLine - startLine;
538
- // Chat box height
539
- const chatBoxHeight = this.getChatBoxHeight();
540
- // ALWAYS pin chat box at absolute bottom
541
- const chatBoxStartRow = Math.max(1, rows - chatBoxHeight + 1);
542
- const scrollEnd = chatBoxStartRow - 1;
543
- writeLock.lock('terminalInput.renderPinned');
544
620
  this.isRendering = true;
621
+ writeLock.lock('terminalInput.render');
545
622
  try {
546
- this.write(ESC.SAVE);
547
- this.write(ESC.HIDE);
548
- // Temporarily reset scroll region to write chat box cleanly
549
- if (this.scrollRegionActive) {
550
- this.write(ESC.RESET_SCROLL);
551
- }
552
- // Clear the chat box area
553
- for (let i = 0; i < chatBoxHeight; i++) {
554
- const row = chatBoxStartRow + i;
555
- if (row <= rows) {
556
- this.write(ESC.TO(row, 1));
557
- this.write(ESC.CLEAR_LINE);
558
- }
559
- }
560
- let currentRow = chatBoxStartRow;
561
- // Render scroll/status indicator on the left (Claude Code style)
562
- const scrollIndicator = this.buildScrollIndicator();
563
- // Meta/status header with scroll indicator
564
- for (const metaLine of metaLines) {
565
- this.write(ESC.TO(currentRow, 1));
566
- this.write(metaLine);
567
- currentRow += 1;
568
- }
569
- // Separator line with scroll status
570
- this.write(ESC.TO(currentRow, 1));
571
- const dividerLabel = scrollIndicator || undefined;
572
- this.write(renderDivider(cols - 2, dividerLabel));
573
- currentRow += 1;
574
- // Render input lines
575
- let finalRow = currentRow;
576
- let finalCol = 3;
577
- for (let i = 0; i < visibleLines.length; i++) {
578
- const rowNum = currentRow + i;
579
- this.write(ESC.TO(rowNum, 1));
580
- const line = visibleLines[i] ?? '';
581
- const isFirstLine = (startLine + i) === 0;
582
- const isCursorLine = i === adjustedCursorLine;
583
- this.write(ESC.BG_DARK);
584
- this.write(ESC.DIM);
585
- this.write(isFirstLine ? this.config.promptChar : this.config.continuationChar);
586
- this.write(ESC.RESET);
587
- this.write(ESC.BG_DARK);
588
- if (isCursorLine) {
589
- const col = Math.min(cursorCol, line.length);
590
- const before = line.slice(0, col);
591
- const at = col < line.length ? line[col] : ' ';
592
- const after = col < line.length ? line.slice(col + 1) : '';
593
- this.write(before);
594
- this.write(ESC.REVERSE + ESC.BOLD);
595
- this.write(at);
596
- this.write(ESC.RESET + ESC.BG_DARK);
597
- this.write(after);
598
- finalRow = rowNum;
599
- finalCol = this.config.promptChar.length + col + 1;
600
- }
601
- else {
602
- this.write(line);
603
- }
604
- // Pad to edge
605
- const lineLen = this.config.promptChar.length + line.length + (isCursorLine && cursorCol >= line.length ? 1 : 0);
606
- const padding = Math.max(0, cols - lineLen - 1);
607
- if (padding > 0)
608
- this.write(' '.repeat(padding));
609
- this.write(ESC.RESET);
610
- }
611
- // Mode controls line with all keyboard shortcuts
612
- const controlRow = currentRow + visibleLines.length;
613
- this.write(ESC.TO(controlRow, 1));
614
- this.write(this.buildModeControls(cols));
615
- // Restore scroll region and cursor
616
- if (this.scrollRegionActive) {
617
- // Restore scroll region
618
- this.write(ESC.SET_SCROLL(1, scrollEnd));
619
- // Restore cursor to where it was before rendering (preserves column position)
620
- this.write(ESC.RESTORE);
621
- }
622
- else {
623
- // Not streaming - position cursor in input box
624
- this.write(ESC.RESTORE);
625
- this.write(ESC.TO(finalRow, Math.min(finalCol, cols)));
626
- }
627
- this.write(ESC.SHOW);
628
- // Update state
629
- this.lastRenderContent = this.buffer;
630
- this.lastRenderCursor = this.cursor;
631
- this.lastStreamingRender = streamingActive ? Date.now() : 0;
632
- if (this.streamingRenderTimer) {
633
- clearTimeout(this.streamingRenderTimer);
634
- this.streamingRenderTimer = null;
635
- }
623
+ // Always render floating right after content (no wasted space)
624
+ this.renderFloatingInputArea();
636
625
  }
637
626
  finally {
638
627
  writeLock.unlock();
@@ -640,221 +629,211 @@ export class TerminalInput extends EventEmitter {
640
629
  }
641
630
  }
642
631
  /**
643
- * Build compact meta line above the divider.
644
- * Shows model/provider and key metrics in a single line.
632
+ * Build the structured input layout according to the UI schema.
645
633
  */
646
- buildMetaLines(width) {
647
- const leftParts = [];
648
- const rightParts = [];
649
- // Model/provider info (left side)
650
- if (this.modelLabel) {
651
- const modelText = this.providerLabel
652
- ? `${this.modelLabel} @ ${this.providerLabel}`
653
- : this.modelLabel;
654
- leftParts.push({ text: modelText, tone: 'info' });
655
- }
656
- // Elapsed time (right side)
657
- if (this.metaElapsedSeconds !== null) {
658
- rightParts.push({ text: `⏱ ${this.formatElapsedLabel(this.metaElapsedSeconds)}`, tone: 'muted' });
659
- }
660
- // Token usage (right side)
661
- if (this.metaTokensUsed !== null) {
662
- const formattedUsed = this.formatTokenCount(this.metaTokensUsed);
663
- const formattedLimit = this.metaTokenLimit ? `/${this.formatTokenCount(this.metaTokenLimit)}` : '';
664
- rightParts.push({ text: `⊛ ${formattedUsed}${formattedLimit}`, tone: 'muted' });
665
- }
666
- // Context remaining warning
667
- const tokensRemaining = this.computeTokensRemaining();
668
- if (tokensRemaining !== null) {
669
- const contextPct = this.contextUsage !== null ? `${100 - this.contextUsage}%` : '';
670
- rightParts.push({ text: `↓${tokensRemaining} ${contextPct}`, tone: this.contextUsage && this.contextUsage > 80 ? 'warn' : 'muted' });
671
- }
672
- // Thinking indicator
673
- if (this.metaThinkingMs !== null) {
674
- leftParts.push({ text: formatThinking(this.metaThinkingMs, this.metaThinkingHasContent), tone: 'info' });
675
- }
676
- 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()) {
677
674
  return [];
678
675
  }
679
- // Render left and right aligned
680
- if (!rightParts.length || width < 50) {
681
- return [renderStatusLine([...leftParts, ...rightParts], width)];
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);
702
+ }
703
+ visibleLength(value) {
704
+ return value.replace(/\x1b\[[0-9;]*m/g, '').length;
705
+ }
706
+ fitToWidth(value, width) {
707
+ const target = Math.max(1, width);
708
+ const visible = this.visibleLength(value);
709
+ if (visible === target) {
710
+ return value;
711
+ }
712
+ if (visible < target) {
713
+ return `${value}${' '.repeat(target - visible)}`;
714
+ }
715
+ return this.truncateToWidth(value, target);
716
+ }
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;
733
+ }
734
+ if (value.includes('\x1b') && !result.endsWith(UI_COLORS.reset)) {
735
+ result += UI_COLORS.reset;
682
736
  }
683
- const leftWidth = Math.max(12, Math.floor(width * 0.5));
684
- const rightWidth = Math.max(14, width - leftWidth - 1);
685
- const leftText = renderStatusLine(leftParts, leftWidth);
686
- const rightText = renderStatusLine(rightParts, rightWidth);
687
- const spacing = Math.max(1, width - this.visibleLength(leftText) - this.visibleLength(rightText));
688
- return [`${leftText}${' '.repeat(spacing)}${rightText}`];
737
+ return result;
689
738
  }
690
739
  /**
691
- * Build mode controls line with all keyboard shortcuts.
692
- * Shows status, all toggles, and contextual information.
693
- * Enhanced with comprehensive feature status display.
694
- */
695
- buildModeControls(cols) {
696
- const width = Math.max(8, cols - 2);
697
- const leftParts = [];
698
- const rightParts = [];
699
- // Streaming indicator with animated spinner
700
- if (this.streamingLabel) {
701
- leftParts.push({ text: `◐ ${this.streamingLabel}`, tone: 'info' });
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}`);
702
757
  }
703
- // Override status (warnings, errors)
758
+ // Override/warning status
704
759
  if (this.overrideStatusMessage) {
705
- leftParts.push({ text: `⚠ ${this.overrideStatusMessage}`, tone: 'warn' });
760
+ segments.push(`${YELLOW}⚠ ${this.overrideStatusMessage}${R}`);
706
761
  }
707
- // Main status message
762
+ // Primary status message
708
763
  if (this.statusMessage) {
709
- leftParts.push({ text: this.statusMessage, tone: this.streamingLabel ? 'muted' : 'info' });
710
- }
711
- // Scrollback indicator removed - scrollback is disabled in alternate screen mode
712
- // === KEYBOARD SHORTCUTS ===
713
- // Interrupt shortcut (during streaming)
714
- if (this.mode === 'streaming' || this.scrollRegionActive) {
715
- leftParts.push({ text: `${this.formatHotkey('esc')} stop`, tone: 'warn' });
716
- }
717
- // Edit mode toggle (Shift+Tab)
718
- const editHotkey = this.formatHotkey('shift+tab');
719
- const editIcon = this.editMode === 'display-edits' ? '✓' : '?';
720
- const editLabel = this.editMode === 'display-edits' ? 'edits:auto' : 'edits:ask';
721
- const editTone = this.editMode === 'display-edits' ? 'success' : 'muted';
722
- leftParts.push({ text: `${editHotkey}${editIcon}${editLabel}`, tone: editTone });
723
- // Verification toggle (Alt+V)
724
- const verifyIcon = this.verificationEnabled ? '✓' : '○';
725
- const verifyHotkey = this.formatHotkey(this.verificationHotkey || 'alt+v');
726
- const verifyLabel = this.verificationEnabled ? 'verify' : 'no-verify';
727
- leftParts.push({ text: `${verifyHotkey}${verifyIcon}${verifyLabel}`, tone: this.verificationEnabled ? 'success' : 'muted' });
728
- // Auto-continue toggle (Alt+C)
729
- const autoIcon = this.autoContinueEnabled ? '↻' : '○';
730
- const continueHotkey = this.formatHotkey(this.autoContinueHotkey || 'alt+c');
731
- const continueLabel = this.autoContinueEnabled ? 'auto' : 'manual';
732
- leftParts.push({ text: `${continueHotkey}${autoIcon}${continueLabel}`, tone: this.autoContinueEnabled ? 'info' : 'muted' });
733
- // Thinking mode toggle (if available)
734
- if (this.thinkingModeLabel) {
735
- const thinkingHotkey = this.formatHotkey(this.thinkingHotkey || '/thinking');
736
- rightParts.push({ text: `${thinkingHotkey}◐${this.thinkingModeLabel}`, tone: 'info' });
737
- }
738
- // === CONTEXTUAL INFO ===
739
- // Queued commands
740
- if (this.queue.length > 0) {
741
- const queueIcon = this.mode === 'streaming' ? '⏳' : '▸';
742
- leftParts.push({ text: `${queueIcon}${this.queue.length}queued`, tone: 'info' });
743
- }
744
- // Scrollback buffer hint removed - scrollback navigation is disabled
745
- // Multi-line indicator
746
- if (this.buffer.includes('\n')) {
747
- const lineCount = this.buffer.split('\n').length;
748
- rightParts.push({ text: `${lineCount}L`, tone: 'muted' });
764
+ segments.push(`${CYAN}${this.statusMessage}${R}`);
749
765
  }
750
- // Paste indicator
751
- if (this.pastePlaceholders.length > 0) {
752
- const latest = this.pastePlaceholders[this.pastePlaceholders.length - 1];
753
- rightParts.push({
754
- text: `paste#${latest.id}+${latest.lineCount}L`,
755
- tone: 'info',
756
- });
766
+ // Queue + paste indicators
767
+ if (this.mode === 'streaming' && this.queue.length > 0) {
768
+ segments.push(`${CYAN}queued ${this.queue.length}${R}`);
757
769
  }
758
- // Context remaining warning with visual indicator
759
- const contextRemaining = this.computeContextRemaining();
760
- if (contextRemaining !== null) {
761
- const tone = contextRemaining <= 10 ? 'warn' : contextRemaining <= 30 ? 'info' : 'muted';
762
- const icon = contextRemaining <= 10 ? '⚠' : '⊛';
763
- const label = contextRemaining === 0 && this.contextUsage !== null
764
- ? `${icon}compact!`
765
- : `${icon}${contextRemaining}%`;
766
- rightParts.push({ text: label, tone });
767
- }
768
- // Model/provider quick reference (compact)
769
- if (this.modelLabel && !this.streamingLabel) {
770
- const shortModel = this.modelLabel.length > 12 ? this.modelLabel.slice(0, 10) + '..' : this.modelLabel;
771
- rightParts.push({ text: shortModel, tone: 'muted' });
772
- }
773
- // Render: left-aligned shortcuts, right-aligned context info
774
- if (!rightParts.length || width < 60) {
775
- const merged = rightParts.length ? [...leftParts, ...rightParts] : leftParts;
776
- return renderStatusLine(merged, width);
777
- }
778
- const leftWidth = Math.max(12, Math.floor(width * 0.65));
779
- const rightWidth = Math.max(14, width - leftWidth - 1);
780
- const leftText = renderStatusLine(leftParts, leftWidth);
781
- const rightText = renderStatusLine(rightParts, rightWidth);
782
- const spacing = Math.max(1, width - this.visibleLength(leftText) - this.visibleLength(rightText));
783
- return `${leftText}${' '.repeat(spacing)}${rightText}`;
784
- }
785
- formatHotkey(hotkey) {
786
- const normalized = hotkey.trim().toLowerCase();
787
- if (!normalized)
788
- return hotkey;
789
- const parts = normalized.split('+').filter(Boolean);
790
- const map = {
791
- shift: '⇧',
792
- sh: '⇧',
793
- alt: '⌥',
794
- option: '⌥',
795
- opt: '⌥',
796
- ctrl: '⌃',
797
- control: '⌃',
798
- cmd: '⌘',
799
- meta: '⌘',
800
- };
801
- const formatted = parts
802
- .map((part) => {
803
- const symbol = map[part];
804
- if (symbol)
805
- return symbol;
806
- return part.length === 1 ? part.toUpperCase() : part.toUpperCase();
807
- })
808
- .join('');
809
- return formatted || hotkey;
810
- }
811
- computeContextRemaining() {
812
- if (this.contextUsage === null) {
813
- return null;
814
- }
815
- return Math.max(0, this.contextAutoCompactThreshold - this.contextUsage);
816
- }
817
- computeTokensRemaining() {
818
- if (this.metaTokensUsed === null || this.metaTokenLimit === null) {
819
- return null;
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}`);
820
773
  }
821
- const remaining = Math.max(0, this.metaTokenLimit - this.metaTokensUsed);
822
- return this.formatTokenCount(remaining);
823
- }
824
- formatElapsedLabel(seconds) {
825
- if (seconds < 60) {
826
- return `${seconds}s`;
774
+ // Model info for quick glance
775
+ if (this.modelInfo) {
776
+ segments.push(`${DIM}${this.modelInfo}${R}`);
827
777
  }
828
- const mins = Math.floor(seconds / 60);
829
- const secs = seconds % 60;
830
- return secs > 0 ? `${mins}m ${secs}s` : `${mins}m`;
831
- }
832
- formatTokenCount(value) {
833
- if (!Number.isFinite(value)) {
834
- return `${value}`;
778
+ // Default hint when idle
779
+ if (!segments.length) {
780
+ segments.push(`${DIM}Type a message or / for commands${R}`);
835
781
  }
836
- if (value >= 1_000_000) {
837
- return `${(value / 1_000_000).toFixed(1)}M`;
838
- }
839
- if (value >= 1_000) {
840
- return `${(value / 1_000).toFixed(1)}k`;
782
+ // Multi-line indicator
783
+ if (this.buffer.includes('\n')) {
784
+ segments.push(`${DIM}${this.buffer.split('\n').length}L${R}`);
841
785
  }
842
- return `${Math.round(value)}`;
843
- }
844
- visibleLength(value) {
845
- const ansiPattern = /\u001B\[[0-?]*[ -/]*[@-~]/g;
846
- return value.replace(ansiPattern, '').length;
786
+ const joined = segments.join(`${DIM} · ${R}`);
787
+ return this.fitToWidth(joined, width);
847
788
  }
848
789
  /**
849
- * Debug-only snapshot used by tests to assert rendered strings without
850
- * 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]
851
794
  */
852
- getDebugUiSnapshot(width) {
853
- const cols = Math.max(8, width ?? this.getSize().cols);
854
- return {
855
- meta: this.buildMetaLines(cols - 2),
856
- controls: this.buildModeControls(cols),
857
- };
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);
858
837
  }
859
838
  /**
860
839
  * Force a re-render
@@ -877,201 +856,40 @@ export class TerminalInput extends EventEmitter {
877
856
  handleResize() {
878
857
  this.lastRenderContent = '';
879
858
  this.lastRenderCursor = -1;
880
- this.resetStreamingRenderThrottle();
881
- // If in scrollback mode, re-render the scrollback view with new dimensions
882
- if (this.isInScrollbackMode) {
883
- this.renderScrollbackView();
884
- }
885
- else {
886
- this.scheduleRender();
887
- }
888
- }
889
- /**
890
- * Enter streaming mode with scroll region.
891
- * Sets up terminal scroll region to exclude chat box.
892
- */
893
- enterStreamingScrollRegion() {
894
- const { rows } = this.getSize();
895
- const chatBoxHeight = this.getChatBoxHeight();
896
- const scrollEnd = Math.max(1, rows - chatBoxHeight);
897
- writeLock.lock('enterStreamingScrollRegion');
898
- try {
899
- // Set scroll region for content area (above chat box)
900
- this.write(ESC.SET_SCROLL(1, scrollEnd));
901
- // Position cursor at current content row
902
- this.write(ESC.TO(Math.min(this.contentRow, scrollEnd), 1));
903
- this.scrollRegionActive = true;
904
- this.setStatusMessage('esc to interrupt');
905
- }
906
- finally {
907
- writeLock.unlock();
908
- }
909
- // Render pinned chat box at bottom
910
- this.forceRender();
911
- }
912
- /**
913
- * Exit streaming mode and restore normal operation.
914
- */
915
- exitStreamingScrollRegion() {
916
- writeLock.lock('exitStreamingScrollRegion');
917
- try {
918
- // Reset scroll region to full terminal
919
- this.write(ESC.RESET_SCROLL);
920
- this.scrollRegionActive = false;
921
- this.setStatusMessage('Ready for prompts');
922
- }
923
- finally {
924
- writeLock.unlock();
925
- }
926
- // Final render
927
- this.forceRender();
928
- }
929
- /**
930
- * Render chat box at bottom - now uses unified renderer.
931
- * @deprecated Use renderPinnedChatBox() directly via render()/forceRender()
932
- */
933
- renderChatBoxAtBottom() {
934
- this.renderPinnedChatBox();
935
- }
936
- /**
937
- * Stream content within the scroll region.
938
- * Content is written directly and scrolls naturally.
939
- */
940
- streamContent(content) {
941
- if (!content)
942
- return;
943
- // Capture content in scrollback buffer
944
- this.addToScrollback(content);
945
- writeLock.lock('streamContent');
946
- try {
947
- // Write content - scroll region handles scrolling
948
- this.write(content);
949
- // Track newlines
950
- const newlines = (content.match(/\n/g) || []).length;
951
- this.contentRow += newlines;
952
- }
953
- finally {
954
- writeLock.unlock();
955
- }
956
- // Throttle chat box updates during streaming
957
- this.scheduleStreamingRender(200);
958
- }
959
- /**
960
- * Enable scroll region (no-op in floating mode).
961
- */
962
- enableScrollRegion() {
963
- // No-op: using pure floating approach
964
- }
965
- /**
966
- * Disable scroll region (no-op in floating mode).
967
- */
968
- disableScrollRegion() {
969
- // No-op: using pure floating approach
970
- }
971
- /**
972
- * Calculate chat box height.
973
- */
974
- getChatBoxHeight() {
975
- return 6; // Fixed: meta + divider + input + controls + buffer
976
- }
977
- /**
978
- * @deprecated Use streamContent() instead
979
- * Register with display's output interceptor - kept for backwards compatibility
980
- */
981
- registerOutputInterceptor(_display) {
982
- // No-op: Use streamContent() for cleaner floating chat box behavior
983
- }
984
- /**
985
- * Write content above the floating chat box.
986
- * Works both during streaming and when idle.
987
- */
988
- writeToScrollRegion(content) {
989
- if (!content)
990
- return;
991
- // Capture content in scrollback buffer
992
- this.addToScrollback(content);
993
- writeLock.lock('writeToScrollRegion');
994
- try {
995
- // Position cursor at content row and write
996
- this.write(ESC.TO(this.contentRow, 1));
997
- this.write(content);
998
- // Track newlines
999
- const newlines = (content.match(/\n/g) || []).length;
1000
- this.contentRow += newlines;
1001
- }
1002
- finally {
1003
- writeLock.unlock();
1004
- }
1005
- // Re-render chat box below new content (only when not streaming)
1006
- if (!this.scrollRegionActive) {
1007
- this.forceRender();
1008
- }
1009
- }
1010
- /**
1011
- * Enter alternate screen buffer.
1012
- * DISABLED: Using terminal-native mode for proper scrollback and text selection.
1013
- */
1014
- enterAlternateScreen() {
1015
- // Disabled - using terminal-native mode
1016
- this.contentRow = 1;
1017
- }
1018
- /**
1019
- * Exit alternate screen buffer.
1020
- * DISABLED: Using terminal-native mode.
1021
- */
1022
- exitAlternateScreen() {
1023
- // Disabled - using terminal-native mode
1024
- }
1025
- /**
1026
- * Check if alternate screen buffer is currently active.
1027
- * Always returns false - using terminal-native mode.
1028
- */
1029
- isAlternateScreenActive() {
1030
- return false;
1031
- }
1032
- /**
1033
- * Get a snapshot of the scrollback buffer (for display on exit).
1034
- */
1035
- getScrollbackSnapshot() {
1036
- return [...this.scrollbackBuffer];
1037
- }
1038
- /**
1039
- * Clear the visible terminal area and reset content position.
1040
- * In terminal-native mode, this just adds newlines to scroll past content
1041
- * rather than clearing history (preserving scrollback).
1042
- */
1043
- clearScreen() {
1044
- writeLock.lock('clearScreen');
1045
- try {
1046
- // In native mode, scroll past existing content rather than clearing
1047
- const { rows } = this.getSize();
1048
- this.write('\n'.repeat(rows));
1049
- this.write(ESC.HOME);
1050
- this.contentRow = 1;
1051
- }
1052
- finally {
1053
- writeLock.unlock();
1054
- }
1055
- }
1056
- /**
1057
- * Reset content position to row 1.
1058
- * Does NOT clear the terminal - content starts from current position.
1059
- */
1060
- resetContentPosition() {
1061
- this.contentRow = 1;
1062
- }
1063
- /**
1064
- * Set the content row explicitly (used after banner is written).
1065
- * This tells the input where content should start flowing from.
1066
- */
1067
- setContentRow(row) {
1068
- this.contentRow = Math.max(1, row);
859
+ this.scheduleRender();
1069
860
  }
1070
- /**
1071
- * Get the current content row position.
1072
- */
1073
- getContentRow() {
1074
- 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
+ });
1075
893
  }
1076
894
  /**
1077
895
  * Dispose and clean up
@@ -1079,10 +897,24 @@ export class TerminalInput extends EventEmitter {
1079
897
  dispose() {
1080
898
  if (this.disposed)
1081
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
+ }
1082
916
  this.disposed = true;
1083
917
  this.enabled = false;
1084
- this.disableScrollRegion();
1085
- this.resetStreamingRenderThrottle();
1086
918
  this.disableBracketedPaste();
1087
919
  this.buffer = '';
1088
920
  this.queue = [];
@@ -1124,13 +956,8 @@ export class TerminalInput extends EventEmitter {
1124
956
  break;
1125
957
  }
1126
958
  }
1127
- /**
1128
- * Handle Alt/Meta key combinations for mode toggles and navigation.
1129
- * All major erosolar-cli features accessible via keyboard shortcuts.
1130
- */
1131
959
  handleMetaKey(key) {
1132
960
  switch (key.name) {
1133
- // === CURSOR MOVEMENT ===
1134
961
  case 'left':
1135
962
  case 'b':
1136
963
  this.moveCursorWordLeft();
@@ -1145,83 +972,14 @@ export class TerminalInput extends EventEmitter {
1145
972
  case 'return':
1146
973
  this.insertNewline();
1147
974
  break;
1148
- // === MODE TOGGLES ===
1149
975
  case 'v':
1150
- // Alt+V: Toggle verification mode (auto-tests after edits)
1151
976
  this.emit('toggleVerify');
1152
977
  break;
1153
978
  case 'c':
1154
- // Alt+C: Toggle auto-continue mode
1155
979
  this.emit('toggleAutoContinue');
1156
980
  break;
1157
- case 't':
1158
- // Alt+T: Toggle/cycle thinking mode
1159
- this.emit('toggleThinking');
1160
- break;
1161
- case 'e':
1162
- // Alt+E: Toggle edit permission mode (ask/auto)
1163
- this.toggleEditMode();
1164
- this.emit('toggleEditMode');
1165
- break;
1166
- case 'x':
1167
- // Alt+X: Clear/compact context
1168
- this.emit('clearContext');
1169
- break;
1170
- // === SCROLLBACK NAVIGATION ===
1171
- case 's':
1172
- // Alt+S: Toggle scrollback mode
1173
- this.toggleScrollbackMode();
1174
- break;
1175
- case 'up':
1176
- // Alt+Up: Quick scroll up into history
1177
- if (!this.isInScrollbackMode) {
1178
- this.scrollUp(10);
1179
- }
1180
- else {
1181
- this.scrollUp(1);
1182
- }
1183
- break;
1184
- case 'down':
1185
- // Alt+Down: Quick scroll down
1186
- if (this.isInScrollbackMode) {
1187
- this.scrollDown(1);
1188
- }
1189
- break;
1190
- case 'pageup':
1191
- // Alt+PageUp: Page up in scrollback
1192
- this.scrollUp(this.getPageSize());
1193
- break;
1194
- case 'pagedown':
1195
- // Alt+PageDown: Page down in scrollback
1196
- this.scrollDown(this.getPageSize());
1197
- break;
1198
- case 'home':
1199
- // Alt+Home: Jump to top of scrollback
1200
- this.scrollToTop();
1201
- break;
1202
- case 'end':
1203
- // Alt+End: Jump to bottom (live)
1204
- this.scrollToBottom();
1205
- break;
1206
981
  }
1207
982
  }
1208
- /**
1209
- * Get page size for scrollback navigation.
1210
- */
1211
- getPageSize() {
1212
- const { rows } = this.getSize();
1213
- const chatBoxHeight = this.getChatBoxHeight();
1214
- return Math.max(5, rows - chatBoxHeight - 2);
1215
- }
1216
- /**
1217
- * Build scroll indicator for the divider line.
1218
- * Scrollback navigation is disabled in alternate screen mode.
1219
- * This returns null - no scroll indicator is shown.
1220
- */
1221
- buildScrollIndicator() {
1222
- // Scrollback navigation disabled - no indicator needed
1223
- return null;
1224
- }
1225
983
  handleSpecialKey(_str, key) {
1226
984
  switch (key.name) {
1227
985
  case 'return':
@@ -1245,54 +1003,38 @@ export class TerminalInput extends EventEmitter {
1245
1003
  this.moveCursorRight();
1246
1004
  return true;
1247
1005
  case 'up':
1248
- // Ctrl+Shift+Up or Shift+Up: Quick scroll up in scrollback
1249
- if ((key.ctrl && key.shift) || key.shift) {
1250
- this.scrollUp(5);
1251
- }
1252
- else {
1253
- this.handleUp();
1254
- }
1006
+ this.handleUp();
1255
1007
  return true;
1256
1008
  case 'down':
1257
- // Ctrl+Shift+Down or Shift+Down: Quick scroll down in scrollback
1258
- if ((key.ctrl && key.shift) || key.shift) {
1259
- this.scrollDown(5);
1260
- }
1261
- else {
1262
- this.handleDown();
1263
- }
1009
+ this.handleDown();
1264
1010
  return true;
1265
1011
  case 'home':
1266
- // Ctrl+Home or in scrollback mode: scroll to top
1267
- if (key.ctrl || this.isInScrollbackMode) {
1268
- this.scrollToTop();
1269
- }
1270
- else {
1271
- this.moveCursorToLineStart();
1272
- }
1012
+ this.moveCursorToLineStart();
1273
1013
  return true;
1274
1014
  case 'end':
1275
- // Ctrl+End or in scrollback mode: scroll to bottom (live mode)
1276
- if (key.ctrl || this.isInScrollbackMode) {
1277
- this.scrollToBottom();
1278
- }
1279
- else {
1280
- this.moveCursorToLineEnd();
1281
- }
1282
- return true;
1283
- case 'pageup':
1284
- // Scrollback disabled in alternate screen mode
1285
- // Users should use terminal's native scrollback if available
1286
- return true;
1287
- case 'pagedown':
1288
- // Scrollback disabled in alternate screen mode
1015
+ this.moveCursorToLineEnd();
1289
1016
  return true;
1290
1017
  case 'tab':
1291
1018
  if (key.shift) {
1292
1019
  this.toggleEditMode();
1293
1020
  return true;
1294
1021
  }
1295
- 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
+ }
1296
1038
  return true;
1297
1039
  }
1298
1040
  return false;
@@ -1310,6 +1052,7 @@ export class TerminalInput extends EventEmitter {
1310
1052
  this.insertPlainText(chunk, insertPos);
1311
1053
  this.cursor = insertPos + chunk.length;
1312
1054
  this.emit('change', this.buffer);
1055
+ this.updateSuggestions();
1313
1056
  this.scheduleRender();
1314
1057
  }
1315
1058
  insertNewline() {
@@ -1334,6 +1077,7 @@ export class TerminalInput extends EventEmitter {
1334
1077
  this.cursor = Math.max(0, this.cursor - 1);
1335
1078
  }
1336
1079
  this.emit('change', this.buffer);
1080
+ this.updateSuggestions();
1337
1081
  this.scheduleRender();
1338
1082
  }
1339
1083
  deleteForward() {
@@ -1561,12 +1305,13 @@ export class TerminalInput extends EventEmitter {
1561
1305
  timestamp: Date.now(),
1562
1306
  });
1563
1307
  this.emit('queue', text);
1564
- this.clear(); // Clear immediately for queued input
1308
+ this.clear(); // Clear immediately for queued input, re-render to update queue display
1565
1309
  }
1566
1310
  else {
1567
- // In idle mode, clear the input first, then emit submit.
1568
- // The prompt will be logged as a visible message by the caller.
1569
- 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
1570
1315
  this.emit('submit', text);
1571
1316
  }
1572
1317
  }
@@ -1583,9 +1328,7 @@ export class TerminalInput extends EventEmitter {
1583
1328
  if (available <= 0)
1584
1329
  return;
1585
1330
  const chunk = clean.slice(0, available);
1586
- const isMultiline = isMultilinePaste(chunk);
1587
- const isShortMultiline = isMultiline && this.shouldInlineMultiline(chunk);
1588
- if (isMultiline && !isShortMultiline) {
1331
+ if (isMultilinePaste(chunk)) {
1589
1332
  this.insertPastePlaceholder(chunk);
1590
1333
  }
1591
1334
  else {
@@ -1649,169 +1392,6 @@ export class TerminalInput extends EventEmitter {
1649
1392
  return { lines, cursorLine, cursorCol };
1650
1393
  }
1651
1394
  // ===========================================================================
1652
- // SCROLLBACK BUFFER
1653
- // ===========================================================================
1654
- /**
1655
- * Add content to the scrollback buffer for history retention
1656
- */
1657
- addToScrollback(content) {
1658
- if (!content)
1659
- return;
1660
- // Split content into lines and add to buffer
1661
- const lines = content.split('\n');
1662
- for (let i = 0; i < lines.length; i++) {
1663
- const line = lines[i];
1664
- if (line !== undefined) {
1665
- // Only add non-empty lines or preserve newlines between content
1666
- if (i < lines.length - 1 || line.length > 0) {
1667
- this.scrollbackBuffer.push(line);
1668
- }
1669
- }
1670
- }
1671
- // Trim buffer if it exceeds max size
1672
- while (this.scrollbackBuffer.length > this.maxScrollbackLines) {
1673
- this.scrollbackBuffer.shift();
1674
- }
1675
- // If we're in live mode (not scrolled up), keep offset at 0
1676
- if (this.scrollbackOffset === 0) {
1677
- this.isInScrollbackMode = false;
1678
- }
1679
- }
1680
- /**
1681
- * Scroll up by a number of lines (PageUp)
1682
- * Note: Scrollback is disabled in alternate screen mode to avoid display corruption.
1683
- * Users should use their terminal's native scrollback or copy/paste features.
1684
- */
1685
- scrollUp(_lines = 10) {
1686
- // Scrollback disabled - alternate screen buffer doesn't support it well
1687
- // The scrollback buffer is still maintained for potential future use
1688
- // Users can select and copy text normally since mouse tracking is off
1689
- }
1690
- /**
1691
- * Scroll down by a number of lines (PageDown)
1692
- * Note: Scrollback disabled - see scrollUp comment
1693
- */
1694
- scrollDown(_lines = 10) {
1695
- // Scrollback disabled
1696
- }
1697
- /**
1698
- * Jump to the top of scrollback buffer
1699
- * DISABLED: Scrollback navigation causes display corruption in alternate screen mode.
1700
- * The scrollback buffer is maintained but cannot be rendered properly.
1701
- */
1702
- scrollToTop() {
1703
- // Disabled - causes display corruption in alternate screen buffer
1704
- }
1705
- /**
1706
- * Jump to the bottom (live mode)
1707
- * DISABLED: Scrollback navigation causes display corruption.
1708
- */
1709
- scrollToBottom() {
1710
- // Reset scrollback state in case it was somehow enabled
1711
- this.scrollbackOffset = 0;
1712
- this.isInScrollbackMode = false;
1713
- }
1714
- /**
1715
- * Toggle scrollback mode on/off (Alt+S hotkey)
1716
- * DISABLED: Scrollback navigation causes display corruption in alternate screen mode.
1717
- */
1718
- toggleScrollbackMode() {
1719
- // Disabled - alternate screen buffer doesn't support manual scrollback rendering
1720
- }
1721
- /**
1722
- * Render the scrollback buffer view.
1723
- * DISABLED: This causes display corruption in alternate screen mode.
1724
- * The alternate screen buffer has its own rendering model that conflicts with
1725
- * manual scroll region manipulation.
1726
- */
1727
- renderScrollbackView() {
1728
- // Disabled - causes display corruption
1729
- }
1730
- /**
1731
- * Build scrollback header with navigation hints
1732
- */
1733
- buildScrollbackHeader(cols, totalLines, startIdx, endIdx) {
1734
- const percentage = Math.round((endIdx / totalLines) * 100);
1735
- // Animated scroll indicator
1736
- const scrollFrames = ['◆', '◇', '◆', '◈'];
1737
- this.scrollIndicatorFrame = (this.scrollIndicatorFrame + 1) % scrollFrames.length;
1738
- const indicator = scrollFrames[this.scrollIndicatorFrame];
1739
- // Build header parts
1740
- const leftPart = theme.info(`${indicator} SCROLLBACK`) +
1741
- theme.ui.muted(` [${startIdx + 1}-${endIdx} of ${totalLines}]`);
1742
- const progressBar = this.buildProgressBar(percentage, 15);
1743
- const rightPart = progressBar +
1744
- theme.ui.muted(` ${percentage}%`) +
1745
- theme.ui.muted(' │ ') +
1746
- theme.primary('PgUp') + theme.ui.muted('/') + theme.primary('PgDn') +
1747
- theme.ui.muted(' scroll · ') +
1748
- theme.primary('End') + theme.ui.muted(' exit');
1749
- const leftLen = this.visibleLength(leftPart);
1750
- const rightLen = this.visibleLength(rightPart);
1751
- const padding = Math.max(1, cols - leftLen - rightLen - 2);
1752
- return `${leftPart}${' '.repeat(padding)}${rightPart}`;
1753
- }
1754
- /**
1755
- * Render visual scroll track on the right side
1756
- */
1757
- renderScrollTrack(cols, contentHeight, totalLines, startIdx, endIdx) {
1758
- if (totalLines <= contentHeight || cols < 40)
1759
- return;
1760
- const trackHeight = contentHeight - 1; // Exclude header
1761
- const viewportRatio = (endIdx - startIdx) / totalLines;
1762
- const positionRatio = startIdx / Math.max(1, totalLines - (endIdx - startIdx));
1763
- // Calculate thumb size and position
1764
- const thumbSize = Math.max(1, Math.round(viewportRatio * trackHeight));
1765
- const thumbStart = Math.round(positionRatio * (trackHeight - thumbSize));
1766
- // Render track on right edge
1767
- for (let i = 0; i < trackHeight; i++) {
1768
- const row = 2 + i; // Start after header
1769
- this.write(ESC.TO(row, cols));
1770
- if (i >= thumbStart && i < thumbStart + thumbSize) {
1771
- // Thumb (viewport indicator)
1772
- this.write(theme.accent('█'));
1773
- }
1774
- else {
1775
- // Track background
1776
- this.write(theme.ui.muted('░'));
1777
- }
1778
- }
1779
- }
1780
- /**
1781
- * Build a visual progress bar
1782
- */
1783
- buildProgressBar(percentage, width = 10) {
1784
- const filled = Math.round((percentage / 100) * width);
1785
- const empty = width - filled;
1786
- const bar = theme.accent('█'.repeat(filled)) +
1787
- theme.ui.muted('░'.repeat(empty));
1788
- return `${theme.ui.muted('[')}${bar}${theme.ui.muted(']')}`;
1789
- }
1790
- /**
1791
- * Get scrollback buffer content (for persistence)
1792
- */
1793
- getScrollbackBuffer() {
1794
- return [...this.scrollbackBuffer];
1795
- }
1796
- /**
1797
- * Load scrollback buffer (for restoration)
1798
- */
1799
- loadScrollbackBuffer(lines) {
1800
- this.scrollbackBuffer = [...lines];
1801
- // Trim if necessary
1802
- while (this.scrollbackBuffer.length > this.maxScrollbackLines) {
1803
- this.scrollbackBuffer.shift();
1804
- }
1805
- }
1806
- /**
1807
- * Clear scrollback buffer
1808
- */
1809
- clearScrollbackBuffer() {
1810
- this.scrollbackBuffer = [];
1811
- this.scrollbackOffset = 0;
1812
- this.isInScrollbackMode = false;
1813
- }
1814
- // ===========================================================================
1815
1395
  // UTILITIES
1816
1396
  // ===========================================================================
1817
1397
  getComposedLength() {
@@ -1884,19 +1464,17 @@ export class TerminalInput extends EventEmitter {
1884
1464
  this.shiftPlaceholders(position, text.length);
1885
1465
  this.buffer = this.buffer.slice(0, position) + text + this.buffer.slice(position);
1886
1466
  }
1887
- shouldInlineMultiline(content) {
1888
- const lines = content.split('\n').length;
1889
- const maxInlineLines = 4;
1890
- const maxInlineChars = 240;
1891
- return lines <= maxInlineLines && content.length <= maxInlineChars;
1892
- }
1893
1467
  findPlaceholderAt(position) {
1894
1468
  return this.pastePlaceholders.find((ph) => position >= ph.start && position < ph.end) ?? null;
1895
1469
  }
1896
- buildPlaceholder(lineCount) {
1470
+ buildPlaceholder(summary) {
1897
1471
  const id = ++this.pasteCounter;
1898
- const plural = lineCount === 1 ? '' : 's';
1899
- 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}"`;
1900
1478
  return { id, placeholder };
1901
1479
  }
1902
1480
  insertPastePlaceholder(content) {
@@ -1904,21 +1482,67 @@ export class TerminalInput extends EventEmitter {
1904
1482
  if (available <= 0)
1905
1483
  return;
1906
1484
  const cleanContent = content.slice(0, available);
1907
- const lineCount = cleanContent.split('\n').length;
1908
- 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);
1909
1495
  const insertPos = this.cursor;
1910
1496
  this.shiftPlaceholders(insertPos, placeholder.length);
1911
1497
  this.pastePlaceholders.push({
1912
1498
  id,
1913
1499
  content: cleanContent,
1914
- lineCount,
1500
+ lineCount: summary.lineCount,
1915
1501
  placeholder,
1916
1502
  start: insertPos,
1917
1503
  end: insertPos + placeholder.length,
1504
+ summary,
1505
+ expanded: false,
1918
1506
  });
1919
1507
  this.buffer = this.buffer.slice(0, insertPos) + placeholder + this.buffer.slice(insertPos);
1920
1508
  this.cursor = insertPos + placeholder.length;
1921
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
+ }
1922
1546
  deletePlaceholder(placeholder) {
1923
1547
  const length = placeholder.end - placeholder.start;
1924
1548
  this.buffer = this.buffer.slice(0, placeholder.start) + this.buffer.slice(placeholder.end);
@@ -1926,11 +1550,7 @@ export class TerminalInput extends EventEmitter {
1926
1550
  this.shiftPlaceholders(placeholder.end, -length, placeholder.id);
1927
1551
  this.cursor = placeholder.start;
1928
1552
  }
1929
- updateContextUsage(value, autoCompactThreshold) {
1930
- if (typeof autoCompactThreshold === 'number' && Number.isFinite(autoCompactThreshold)) {
1931
- const boundedThreshold = Math.max(1, Math.min(100, Math.round(autoCompactThreshold)));
1932
- this.contextAutoCompactThreshold = boundedThreshold;
1933
- }
1553
+ updateContextUsage(value) {
1934
1554
  if (value === null || !Number.isFinite(value)) {
1935
1555
  this.contextUsage = null;
1936
1556
  }
@@ -1957,28 +1577,6 @@ export class TerminalInput extends EventEmitter {
1957
1577
  const next = this.editMode === 'display-edits' ? 'ask-permission' : 'display-edits';
1958
1578
  this.setEditMode(next);
1959
1579
  }
1960
- scheduleStreamingRender(delayMs) {
1961
- if (this.streamingRenderTimer)
1962
- return;
1963
- const wait = Math.max(16, delayMs);
1964
- this.streamingRenderTimer = setTimeout(() => {
1965
- this.streamingRenderTimer = null;
1966
- // During streaming, only update chat box (not full render)
1967
- if (this.scrollRegionActive) {
1968
- this.renderChatBoxAtBottom();
1969
- }
1970
- else {
1971
- this.render();
1972
- }
1973
- }, wait);
1974
- }
1975
- resetStreamingRenderThrottle() {
1976
- if (this.streamingRenderTimer) {
1977
- clearTimeout(this.streamingRenderTimer);
1978
- this.streamingRenderTimer = null;
1979
- }
1980
- this.lastStreamingRender = 0;
1981
- }
1982
1580
  scheduleRender() {
1983
1581
  if (!this.canRender())
1984
1582
  return;