erosolar-cli 1.7.286 → 1.7.290

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (323) hide show
  1. package/README.md +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 +0 -1
  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/toolPreconditions.d.ts.map +1 -1
  97. package/dist/core/toolPreconditions.js +14 -0
  98. package/dist/core/toolPreconditions.js.map +1 -1
  99. package/dist/core/toolRuntime.d.ts +1 -22
  100. package/dist/core/toolRuntime.d.ts.map +1 -1
  101. package/dist/core/toolRuntime.js +5 -0
  102. package/dist/core/toolRuntime.js.map +1 -1
  103. package/dist/core/toolValidation.d.ts.map +1 -1
  104. package/dist/core/toolValidation.js +3 -14
  105. package/dist/core/toolValidation.js.map +1 -1
  106. package/dist/core/unified/errors.d.ts +189 -0
  107. package/dist/core/unified/errors.d.ts.map +1 -0
  108. package/dist/core/unified/errors.js +497 -0
  109. package/dist/core/unified/errors.js.map +1 -0
  110. package/dist/core/unified/index.d.ts +19 -0
  111. package/dist/core/unified/index.d.ts.map +1 -0
  112. package/dist/core/unified/index.js +68 -0
  113. package/dist/core/unified/index.js.map +1 -0
  114. package/dist/core/unified/schema.d.ts +101 -0
  115. package/dist/core/unified/schema.d.ts.map +1 -0
  116. package/dist/core/unified/schema.js +350 -0
  117. package/dist/core/unified/schema.js.map +1 -0
  118. package/dist/core/unified/toolRuntime.d.ts +179 -0
  119. package/dist/core/unified/toolRuntime.d.ts.map +1 -0
  120. package/dist/core/unified/toolRuntime.js +517 -0
  121. package/dist/core/unified/toolRuntime.js.map +1 -0
  122. package/dist/core/unified/tools.d.ts +127 -0
  123. package/dist/core/unified/tools.d.ts.map +1 -0
  124. package/dist/core/unified/tools.js +1333 -0
  125. package/dist/core/unified/tools.js.map +1 -0
  126. package/dist/core/unified/types.d.ts +352 -0
  127. package/dist/core/unified/types.d.ts.map +1 -0
  128. package/dist/core/unified/types.js +12 -0
  129. package/dist/core/unified/types.js.map +1 -0
  130. package/dist/core/unified/version.d.ts +209 -0
  131. package/dist/core/unified/version.d.ts.map +1 -0
  132. package/dist/core/unified/version.js +454 -0
  133. package/dist/core/unified/version.js.map +1 -0
  134. package/dist/core/validationRunner.d.ts +3 -1
  135. package/dist/core/validationRunner.d.ts.map +1 -1
  136. package/dist/core/validationRunner.js.map +1 -1
  137. package/dist/headless/headlessApp.d.ts.map +1 -1
  138. package/dist/headless/headlessApp.js +0 -21
  139. package/dist/headless/headlessApp.js.map +1 -1
  140. package/dist/mcp/sseClient.d.ts.map +1 -1
  141. package/dist/mcp/sseClient.js +18 -9
  142. package/dist/mcp/sseClient.js.map +1 -1
  143. package/dist/plugins/tools/build/buildPlugin.d.ts +6 -0
  144. package/dist/plugins/tools/build/buildPlugin.d.ts.map +1 -1
  145. package/dist/plugins/tools/build/buildPlugin.js +10 -4
  146. package/dist/plugins/tools/build/buildPlugin.js.map +1 -1
  147. package/dist/plugins/tools/nodeDefaults.d.ts.map +1 -1
  148. package/dist/plugins/tools/nodeDefaults.js +2 -0
  149. package/dist/plugins/tools/nodeDefaults.js.map +1 -1
  150. package/dist/plugins/tools/security/securityPlugin.d.ts +3 -0
  151. package/dist/plugins/tools/security/securityPlugin.d.ts.map +1 -0
  152. package/dist/plugins/tools/security/securityPlugin.js +12 -0
  153. package/dist/plugins/tools/security/securityPlugin.js.map +1 -0
  154. package/dist/runtime/agentSession.d.ts +2 -2
  155. package/dist/runtime/agentSession.d.ts.map +1 -1
  156. package/dist/runtime/agentSession.js +2 -2
  157. package/dist/runtime/agentSession.js.map +1 -1
  158. package/dist/security/active-stack-security.d.ts +112 -0
  159. package/dist/security/active-stack-security.d.ts.map +1 -0
  160. package/dist/security/active-stack-security.js +296 -0
  161. package/dist/security/active-stack-security.js.map +1 -0
  162. package/dist/security/advanced-persistence-research.d.ts +92 -0
  163. package/dist/security/advanced-persistence-research.d.ts.map +1 -0
  164. package/dist/security/advanced-persistence-research.js +195 -0
  165. package/dist/security/advanced-persistence-research.js.map +1 -0
  166. package/dist/security/advanced-targeting.d.ts +119 -0
  167. package/dist/security/advanced-targeting.d.ts.map +1 -0
  168. package/dist/security/advanced-targeting.js +233 -0
  169. package/dist/security/advanced-targeting.js.map +1 -0
  170. package/dist/security/assessment/vulnerabilityAssessment.d.ts +104 -0
  171. package/dist/security/assessment/vulnerabilityAssessment.d.ts.map +1 -0
  172. package/dist/security/assessment/vulnerabilityAssessment.js +315 -0
  173. package/dist/security/assessment/vulnerabilityAssessment.js.map +1 -0
  174. package/dist/security/authorization/securityAuthorization.d.ts +88 -0
  175. package/dist/security/authorization/securityAuthorization.d.ts.map +1 -0
  176. package/dist/security/authorization/securityAuthorization.js +172 -0
  177. package/dist/security/authorization/securityAuthorization.js.map +1 -0
  178. package/dist/security/comprehensive-targeting.d.ts +85 -0
  179. package/dist/security/comprehensive-targeting.d.ts.map +1 -0
  180. package/dist/security/comprehensive-targeting.js +438 -0
  181. package/dist/security/comprehensive-targeting.js.map +1 -0
  182. package/dist/security/global-security-integration.d.ts +91 -0
  183. package/dist/security/global-security-integration.d.ts.map +1 -0
  184. package/dist/security/global-security-integration.js +218 -0
  185. package/dist/security/global-security-integration.js.map +1 -0
  186. package/dist/security/index.d.ts +38 -0
  187. package/dist/security/index.d.ts.map +1 -0
  188. package/dist/security/index.js +47 -0
  189. package/dist/security/index.js.map +1 -0
  190. package/dist/security/persistence-analyzer.d.ts +56 -0
  191. package/dist/security/persistence-analyzer.d.ts.map +1 -0
  192. package/dist/security/persistence-analyzer.js +187 -0
  193. package/dist/security/persistence-analyzer.js.map +1 -0
  194. package/dist/security/persistence-cli.d.ts +36 -0
  195. package/dist/security/persistence-cli.d.ts.map +1 -0
  196. package/dist/security/persistence-cli.js +160 -0
  197. package/dist/security/persistence-cli.js.map +1 -0
  198. package/dist/security/persistence-research.d.ts +92 -0
  199. package/dist/security/persistence-research.d.ts.map +1 -0
  200. package/dist/security/persistence-research.js +364 -0
  201. package/dist/security/persistence-research.js.map +1 -0
  202. package/dist/security/research/persistenceResearch.d.ts +97 -0
  203. package/dist/security/research/persistenceResearch.d.ts.map +1 -0
  204. package/dist/security/research/persistenceResearch.js +282 -0
  205. package/dist/security/research/persistenceResearch.js.map +1 -0
  206. package/dist/security/security-integration.d.ts +74 -0
  207. package/dist/security/security-integration.d.ts.map +1 -0
  208. package/dist/security/security-integration.js +137 -0
  209. package/dist/security/security-integration.js.map +1 -0
  210. package/dist/security/security-testing-framework.d.ts +112 -0
  211. package/dist/security/security-testing-framework.d.ts.map +1 -0
  212. package/dist/security/security-testing-framework.js +364 -0
  213. package/dist/security/security-testing-framework.js.map +1 -0
  214. package/dist/security/simulation/attackSimulation.d.ts +93 -0
  215. package/dist/security/simulation/attackSimulation.d.ts.map +1 -0
  216. package/dist/security/simulation/attackSimulation.js +341 -0
  217. package/dist/security/simulation/attackSimulation.js.map +1 -0
  218. package/dist/security/strategic-operations.d.ts +100 -0
  219. package/dist/security/strategic-operations.d.ts.map +1 -0
  220. package/dist/security/strategic-operations.js +276 -0
  221. package/dist/security/strategic-operations.js.map +1 -0
  222. package/dist/security/tool-security-wrapper.d.ts +58 -0
  223. package/dist/security/tool-security-wrapper.d.ts.map +1 -0
  224. package/dist/security/tool-security-wrapper.js +156 -0
  225. package/dist/security/tool-security-wrapper.js.map +1 -0
  226. package/dist/shell/claudeCodeStreamHandler.d.ts +145 -0
  227. package/dist/shell/claudeCodeStreamHandler.d.ts.map +1 -0
  228. package/dist/shell/claudeCodeStreamHandler.js +322 -0
  229. package/dist/shell/claudeCodeStreamHandler.js.map +1 -0
  230. package/dist/shell/inputQueueManager.d.ts +144 -0
  231. package/dist/shell/inputQueueManager.d.ts.map +1 -0
  232. package/dist/shell/inputQueueManager.js +290 -0
  233. package/dist/shell/inputQueueManager.js.map +1 -0
  234. package/dist/shell/interactiveShell.d.ts +7 -22
  235. package/dist/shell/interactiveShell.d.ts.map +1 -1
  236. package/dist/shell/interactiveShell.js +159 -229
  237. package/dist/shell/interactiveShell.js.map +1 -1
  238. package/dist/shell/metricsTracker.d.ts +60 -0
  239. package/dist/shell/metricsTracker.d.ts.map +1 -0
  240. package/dist/shell/metricsTracker.js +119 -0
  241. package/dist/shell/metricsTracker.js.map +1 -0
  242. package/dist/shell/shellApp.d.ts +0 -2
  243. package/dist/shell/shellApp.d.ts.map +1 -1
  244. package/dist/shell/shellApp.js +9 -40
  245. package/dist/shell/shellApp.js.map +1 -1
  246. package/dist/shell/streamingOutputManager.d.ts +115 -0
  247. package/dist/shell/streamingOutputManager.d.ts.map +1 -0
  248. package/dist/shell/streamingOutputManager.js +225 -0
  249. package/dist/shell/streamingOutputManager.js.map +1 -0
  250. package/dist/shell/systemPrompt.d.ts.map +1 -1
  251. package/dist/shell/systemPrompt.js +4 -1
  252. package/dist/shell/systemPrompt.js.map +1 -1
  253. package/dist/shell/terminalInput.d.ts +157 -87
  254. package/dist/shell/terminalInput.d.ts.map +1 -1
  255. package/dist/shell/terminalInput.js +655 -558
  256. package/dist/shell/terminalInput.js.map +1 -1
  257. package/dist/shell/terminalInputAdapter.d.ts +35 -28
  258. package/dist/shell/terminalInputAdapter.d.ts.map +1 -1
  259. package/dist/shell/terminalInputAdapter.js +50 -26
  260. package/dist/shell/terminalInputAdapter.js.map +1 -1
  261. package/dist/subagents/taskRunner.d.ts +1 -7
  262. package/dist/subagents/taskRunner.d.ts.map +1 -1
  263. package/dist/subagents/taskRunner.js +47 -180
  264. package/dist/subagents/taskRunner.js.map +1 -1
  265. package/dist/tools/securityTools.d.ts +22 -0
  266. package/dist/tools/securityTools.d.ts.map +1 -0
  267. package/dist/tools/securityTools.js +448 -0
  268. package/dist/tools/securityTools.js.map +1 -0
  269. package/dist/ui/ShellUIAdapter.d.ts.map +1 -1
  270. package/dist/ui/ShellUIAdapter.js +12 -13
  271. package/dist/ui/ShellUIAdapter.js.map +1 -1
  272. package/dist/ui/display.d.ts +45 -24
  273. package/dist/ui/display.d.ts.map +1 -1
  274. package/dist/ui/display.js +259 -140
  275. package/dist/ui/display.js.map +1 -1
  276. package/dist/ui/persistentPrompt.d.ts +50 -0
  277. package/dist/ui/persistentPrompt.d.ts.map +1 -0
  278. package/dist/ui/persistentPrompt.js +92 -0
  279. package/dist/ui/persistentPrompt.js.map +1 -0
  280. package/dist/ui/terminalUISchema.d.ts +195 -0
  281. package/dist/ui/terminalUISchema.d.ts.map +1 -0
  282. package/dist/ui/terminalUISchema.js +113 -0
  283. package/dist/ui/terminalUISchema.js.map +1 -0
  284. package/dist/ui/theme.d.ts.map +1 -1
  285. package/dist/ui/theme.js +8 -6
  286. package/dist/ui/theme.js.map +1 -1
  287. package/dist/ui/toolDisplay.d.ts +158 -0
  288. package/dist/ui/toolDisplay.d.ts.map +1 -1
  289. package/dist/ui/toolDisplay.js +348 -0
  290. package/dist/ui/toolDisplay.js.map +1 -1
  291. package/dist/ui/unified/layout.d.ts +0 -1
  292. package/dist/ui/unified/layout.d.ts.map +1 -1
  293. package/dist/ui/unified/layout.js +25 -15
  294. package/dist/ui/unified/layout.js.map +1 -1
  295. package/package.json +1 -1
  296. package/scripts/deploy-security-capabilities.js +178 -0
  297. package/dist/core/hooks.d.ts +0 -113
  298. package/dist/core/hooks.d.ts.map +0 -1
  299. package/dist/core/hooks.js +0 -267
  300. package/dist/core/hooks.js.map +0 -1
  301. package/dist/core/metricsTracker.d.ts +0 -122
  302. package/dist/core/metricsTracker.d.ts.map +0 -1
  303. package/dist/core/metricsTracker.js.map +0 -1
  304. package/dist/core/securityAssessment.d.ts +0 -91
  305. package/dist/core/securityAssessment.d.ts.map +0 -1
  306. package/dist/core/securityAssessment.js +0 -580
  307. package/dist/core/securityAssessment.js.map +0 -1
  308. package/dist/core/verification.d.ts +0 -137
  309. package/dist/core/verification.d.ts.map +0 -1
  310. package/dist/core/verification.js +0 -323
  311. package/dist/core/verification.js.map +0 -1
  312. package/dist/subagents/agentConfig.d.ts +0 -27
  313. package/dist/subagents/agentConfig.d.ts.map +0 -1
  314. package/dist/subagents/agentConfig.js +0 -89
  315. package/dist/subagents/agentConfig.js.map +0 -1
  316. package/dist/subagents/agentRegistry.d.ts +0 -33
  317. package/dist/subagents/agentRegistry.d.ts.map +0 -1
  318. package/dist/subagents/agentRegistry.js +0 -162
  319. package/dist/subagents/agentRegistry.js.map +0 -1
  320. package/dist/utils/frontmatter.d.ts +0 -10
  321. package/dist/utils/frontmatter.d.ts.map +0 -1
  322. package/dist/utils/frontmatter.js +0 -78
  323. package/dist/utils/frontmatter.js.map +0 -1
@@ -3,18 +3,15 @@
3
3
  *
4
4
  * Design principles:
5
5
  * - Single source of truth for input state
6
- * - One bottom-pinned chat box for the entire session (no inline anchors)
7
6
  * - Native bracketed paste support (no heuristics)
8
7
  * - Clean cursor model with render-time wrapping
9
8
  * - State machine for different input modes
10
9
  * - No readline dependency for display
11
10
  */
12
11
  import { EventEmitter } from 'node:events';
13
- import { isMultilinePaste } from '../core/multilinePasteHandler.js';
12
+ import { isMultilinePaste, generatePasteSummary } from '../core/multilinePasteHandler.js';
14
13
  import { writeLock } from '../ui/writeLock.js';
15
- import { renderDivider, renderStatusLine } from '../ui/unified/layout.js';
16
- import { isStreamingMode } from '../ui/globalWriteLock.js';
17
- import { formatThinking } from '../ui/toolDisplay.js';
14
+ import { UI_COLORS, DEFAULT_UI_CONFIG, } from '../ui/terminalUISchema.js';
18
15
  // ANSI escape codes
19
16
  const ESC = {
20
17
  // Cursor control
@@ -24,6 +21,9 @@ const ESC = {
24
21
  SHOW: '\x1b[?25h',
25
22
  TO: (row, col) => `\x1b[${row};${col}H`,
26
23
  TO_COL: (col) => `\x1b[${col}G`,
24
+ // Screen control
25
+ CLEAR_SCREEN: '\x1b[2J',
26
+ HOME: '\x1b[H',
27
27
  // Line control
28
28
  CLEAR_LINE: '\x1b[2K',
29
29
  CLEAR_TO_END: '\x1b[0J',
@@ -69,49 +69,56 @@ export class TerminalInput extends EventEmitter {
69
69
  statusMessage = null;
70
70
  overrideStatusMessage = null; // Secondary status (warnings, etc.)
71
71
  streamingLabel = null; // Streaming progress indicator
72
- metaElapsedSeconds = null; // Optional elapsed time for header line
73
- metaTokensUsed = null; // Optional token usage
74
- metaTokenLimit = null; // Optional token window
75
- metaThinkingMs = null; // Optional thinking duration
76
- metaThinkingHasContent = false; // Whether collapsed thinking content exists
77
- reservedLines = 2;
72
+ reservedLines = 6; // Lines reserved for input area at bottom
78
73
  scrollRegionActive = false;
79
74
  lastRenderContent = '';
80
75
  lastRenderCursor = -1;
81
76
  renderDirty = false;
82
77
  isRendering = false;
83
78
  pinnedTopRows = 0;
79
+ inlineAnchorRow = null;
80
+ inlineLayout = false;
81
+ anchorProvider = null;
82
+ // Flow mode: when true, renders inline after content (no absolute positioning)
83
+ flowMode = true;
84
+ flowModeRenderedLines = 0; // Track lines rendered for clearing
85
+ contentEndRow = 0; // Row where content ends (for idle mode positioning)
86
+ // Command suggestions (Claude Code style auto-complete)
87
+ commandSuggestions = [];
88
+ filteredSuggestions = [];
89
+ selectedSuggestionIndex = 0;
90
+ showSuggestions = false;
91
+ maxVisibleSuggestions = 10;
84
92
  // Lifecycle
85
93
  disposed = false;
86
94
  enabled = true;
87
95
  contextUsage = null;
88
- contextAutoCompactThreshold = 90;
89
- // Track current content row in scroll region (starts at top, moves down)
90
- contentRow = 1;
91
- thinkingModeLabel = null;
92
96
  editMode = 'display-edits';
93
97
  verificationEnabled = true;
94
98
  autoContinueEnabled = false;
95
99
  verificationHotkey = 'alt+v';
96
100
  autoContinueHotkey = 'alt+c';
97
- thinkingHotkey = '/thinking';
98
- modelLabel = null;
99
- providerLabel = null;
100
101
  // Output interceptor cleanup
101
102
  outputInterceptorCleanup;
102
- // Streaming render throttle
103
- lastStreamingRender = 0;
104
- streamingRenderInterval = 250; // ms between renders during streaming
103
+ // Metrics tracking for status bar
104
+ streamingStartTime = null;
105
+ tokensUsed = 0;
106
+ thinkingEnabled = true;
107
+ modelInfo = null; // Provider · Model info
108
+ // Streaming input area render timer (updates elapsed time display)
105
109
  streamingRenderTimer = null;
110
+ // Unified UI initialization flag
111
+ unifiedUIInitialized = false;
106
112
  constructor(writeStream = process.stdout, config = {}) {
107
113
  super();
108
114
  this.out = writeStream;
115
+ // Use schema defaults for configuration consistency
109
116
  this.config = {
110
- maxLines: config.maxLines ?? 1000,
111
- maxLength: config.maxLength ?? 10000,
117
+ maxLines: config.maxLines ?? DEFAULT_UI_CONFIG.inputArea.maxLines,
118
+ maxLength: config.maxLength ?? DEFAULT_UI_CONFIG.inputArea.maxLength,
112
119
  maxQueueSize: config.maxQueueSize ?? 100,
113
- promptChar: config.promptChar ?? '> ',
114
- continuationChar: config.continuationChar ?? '│ ',
120
+ promptChar: config.promptChar ?? DEFAULT_UI_CONFIG.inputArea.promptChar,
121
+ continuationChar: config.continuationChar ?? DEFAULT_UI_CONFIG.inputArea.continuationChar,
115
122
  };
116
123
  }
117
124
  // ===========================================================================
@@ -190,45 +197,453 @@ export class TerminalInput extends EventEmitter {
190
197
  if (handled)
191
198
  return;
192
199
  }
200
+ // Handle '?' for help hint (if buffer is empty)
201
+ if (str === '?' && this.buffer.length === 0) {
202
+ this.emit('showHelp');
203
+ return;
204
+ }
193
205
  // Insert printable characters
194
206
  if (str && !key?.ctrl && !key?.meta) {
195
207
  this.insertText(str);
196
208
  }
197
209
  }
210
+ // Banner content to write on init (set via setBannerContent before initializeUnifiedUI)
211
+ bannerContent = null;
212
+ /**
213
+ * Set banner content to be written when unified UI initializes.
214
+ */
215
+ setBannerContent(content) {
216
+ this.bannerContent = content;
217
+ }
218
+ /**
219
+ * Initialize the unified UI system.
220
+ *
221
+ * Creates a floating bottom chat box:
222
+ * 1. Clear screen
223
+ * 2. Render input area at row 1 (floating at "bottom" of empty content)
224
+ * 3. Track input area position
225
+ * 4. Banner will be streamed, pushing input area down
226
+ */
227
+ initializeUnifiedUI() {
228
+ if (this.unifiedUIInitialized) {
229
+ return;
230
+ }
231
+ // Reserve lines for input area
232
+ this.reservedLines = 6; // status + model + divider + input + divider + controls
233
+ // Hide cursor during setup
234
+ this.write(ESC.HIDE);
235
+ // Clear screen
236
+ this.write(ESC.HOME);
237
+ this.write(ESC.CLEAR_SCREEN);
238
+ // Position at top
239
+ this.write(ESC.TO(1, 1));
240
+ // Stream banner first (if set)
241
+ if (this.bannerContent) {
242
+ process.stdout.write(this.bannerContent + '\n\n');
243
+ }
244
+ // Mark unified UI as initialized
245
+ this.unifiedUIInitialized = true;
246
+ // Render input area at current cursor position (floating after content)
247
+ this.inputAreaStartRow = this.getCurrentRow();
248
+ this.renderFloatingInputArea();
249
+ // Show cursor
250
+ this.write(ESC.SHOW);
251
+ }
252
+ // Track where the floating input area starts
253
+ inputAreaStartRow = 1;
254
+ /**
255
+ * Get approximate current cursor row (based on content)
256
+ */
257
+ getCurrentRow() {
258
+ // We can't easily query cursor position, so track it
259
+ // For now, estimate based on banner content
260
+ if (this.bannerContent) {
261
+ return this.bannerContent.split('\n').length + 2;
262
+ }
263
+ return 1;
264
+ }
265
+ /**
266
+ * Render floating input area at tracked position.
267
+ * Updates in place using absolute positioning.
268
+ */
269
+ renderFloatingInputArea() {
270
+ const { cols } = this.getSize();
271
+ const divider = '─'.repeat(cols - 1);
272
+ const { dim: DIM, reset: R } = UI_COLORS;
273
+ const startRow = this.inputAreaStartRow;
274
+ // Hide cursor during update
275
+ this.write(ESC.HIDE);
276
+ let currentRow = startRow;
277
+ // Clear input area lines (using absolute positioning)
278
+ for (let i = 0; i < this.reservedLines; i++) {
279
+ this.write(ESC.TO(startRow + i, 1));
280
+ this.write(ESC.CLEAR_LINE);
281
+ }
282
+ // Status bar
283
+ this.write(ESC.TO(currentRow, 1));
284
+ this.write(this.buildStatusBar(cols));
285
+ currentRow++;
286
+ // Model info line (if set)
287
+ if (this.modelInfo) {
288
+ this.write(ESC.TO(currentRow, 1));
289
+ let modelLine = `${DIM}${this.modelInfo}${R}`;
290
+ if (this.contextUsage !== null) {
291
+ const rem = Math.max(0, 100 - this.contextUsage);
292
+ if (rem < 10)
293
+ modelLine += ` ${UI_COLORS.red}⚠ ctx: ${rem}%${R}`;
294
+ else if (rem < 25)
295
+ modelLine += ` ${UI_COLORS.yellow}! ctx: ${rem}%${R}`;
296
+ else
297
+ modelLine += ` ${DIM}· ctx: ${rem}%${R}`;
298
+ }
299
+ this.write(modelLine);
300
+ currentRow++;
301
+ }
302
+ // Top divider
303
+ this.write(ESC.TO(currentRow, 1));
304
+ this.write(divider);
305
+ currentRow++;
306
+ // Input line with prompt and buffer content
307
+ const { lines } = this.wrapBuffer(cols - 4);
308
+ const displayLine = lines[0] ?? '';
309
+ this.write(ESC.TO(currentRow, 1));
310
+ this.write(ESC.BG_DARK + ESC.DIM + this.config.promptChar + ESC.RESET);
311
+ this.write(ESC.BG_DARK + displayLine);
312
+ const padding = Math.max(0, cols - this.config.promptChar.length - displayLine.length - 1);
313
+ if (padding > 0)
314
+ this.write(' '.repeat(padding));
315
+ this.write(ESC.RESET);
316
+ // Position cursor in input field
317
+ const cursorCol = this.config.promptChar.length + Math.min(this.cursor, displayLine.length) + 1;
318
+ this.write(ESC.TO(currentRow, cursorCol));
319
+ currentRow++;
320
+ // Bottom divider
321
+ this.write(ESC.TO(currentRow, 1));
322
+ this.write(divider);
323
+ currentRow++;
324
+ // Mode controls
325
+ this.write(ESC.TO(currentRow, 1));
326
+ this.write(this.buildModeControls(cols));
327
+ // Position cursor back in input line
328
+ const inputRow = startRow + (this.modelInfo ? 3 : 2);
329
+ const inputCol = this.config.promptChar.length + Math.min(this.cursor, displayLine.length) + 1;
330
+ this.write(ESC.TO(inputRow, inputCol));
331
+ this.write(ESC.SHOW);
332
+ // Update tracking
333
+ this.lastRenderContent = this.buffer;
334
+ this.lastRenderCursor = this.cursor;
335
+ }
336
+ /**
337
+ * Render input area at a specific row (inline, not pinned to bottom).
338
+ * Used on launch for compact layout.
339
+ */
340
+ renderInlineInputArea(startRow) {
341
+ const { cols } = this.getSize();
342
+ const divider = '─'.repeat(cols - 1);
343
+ // Move to start row
344
+ this.write(ESC.TO(startRow, 1));
345
+ // Status bar
346
+ process.stdout.write(this.buildStatusBar(cols) + '\n');
347
+ // Model info line (if set)
348
+ if (this.modelInfo) {
349
+ const { dim: DIM, reset: R } = UI_COLORS;
350
+ let modelLine = `${DIM}${this.modelInfo}${R}`;
351
+ if (this.contextUsage !== null) {
352
+ const rem = Math.max(0, 100 - this.contextUsage);
353
+ if (rem < 10)
354
+ modelLine += ` ${UI_COLORS.red}⚠ ctx: ${rem}%${R}`;
355
+ else if (rem < 25)
356
+ modelLine += ` ${UI_COLORS.yellow}! ctx: ${rem}%${R}`;
357
+ else
358
+ modelLine += ` ${DIM}· ctx: ${rem}%${R}`;
359
+ }
360
+ process.stdout.write(modelLine + '\n');
361
+ }
362
+ // Top divider
363
+ process.stdout.write(divider + '\n');
364
+ // Input line with prompt
365
+ process.stdout.write(ESC.BG_DARK + ESC.DIM + this.config.promptChar + ESC.RESET);
366
+ process.stdout.write(ESC.BG_DARK + ' '.repeat(Math.max(0, cols - this.config.promptChar.length - 1)) + ESC.RESET + '\n');
367
+ // Bottom divider
368
+ process.stdout.write(divider + '\n');
369
+ // Mode controls
370
+ process.stdout.write(this.buildModeControls(cols) + '\n');
371
+ // Position cursor in input area
372
+ this.write(ESC.TO(startRow + (this.modelInfo ? 3 : 2), this.config.promptChar.length + 1));
373
+ }
374
+ /**
375
+ * Render input area at current cursor position (inline, not pinned).
376
+ * Used after streaming ends - renders input below the streamed content.
377
+ */
378
+ renderInlineInputAreaAtCursor() {
379
+ const { cols } = this.getSize();
380
+ const divider = '─'.repeat(cols - 1);
381
+ const { dim: DIM, reset: R } = UI_COLORS;
382
+ // Status bar - shows "Type a message" hint
383
+ process.stdout.write(this.buildStatusBar(cols) + '\n');
384
+ // Model info line (if set)
385
+ if (this.modelInfo) {
386
+ let modelLine = `${DIM}${this.modelInfo}${R}`;
387
+ if (this.contextUsage !== null) {
388
+ const rem = Math.max(0, 100 - this.contextUsage);
389
+ if (rem < 10)
390
+ modelLine += ` ${UI_COLORS.red}⚠ ctx: ${rem}%${R}`;
391
+ else if (rem < 25)
392
+ modelLine += ` ${UI_COLORS.yellow}! ctx: ${rem}%${R}`;
393
+ else
394
+ modelLine += ` ${DIM}· ctx: ${rem}%${R}`;
395
+ }
396
+ process.stdout.write(modelLine + '\n');
397
+ }
398
+ // Top divider
399
+ process.stdout.write(divider + '\n');
400
+ // Input line with prompt and any buffer content
401
+ const { lines, cursorCol } = this.wrapBuffer(cols - 4);
402
+ const displayLine = lines[0] ?? '';
403
+ process.stdout.write(ESC.BG_DARK + ESC.DIM + this.config.promptChar + ESC.RESET);
404
+ process.stdout.write(ESC.BG_DARK + displayLine);
405
+ const padding = Math.max(0, cols - this.config.promptChar.length - displayLine.length - 1);
406
+ if (padding > 0)
407
+ process.stdout.write(' '.repeat(padding));
408
+ process.stdout.write(ESC.RESET + '\n');
409
+ // Bottom divider
410
+ process.stdout.write(divider + '\n');
411
+ // Mode controls
412
+ process.stdout.write(this.buildModeControls(cols) + '\n');
413
+ // Show cursor
414
+ this.write(ESC.SHOW);
415
+ // Update tracking
416
+ this.lastRenderContent = this.buffer;
417
+ this.lastRenderCursor = this.cursor;
418
+ }
198
419
  /**
199
420
  * Set the input mode
200
421
  *
201
- * Streaming keeps the scroll region active so the prompt/status stay pinned
202
- * below the streaming output. When streaming ends, we refresh the input area.
422
+ * Streaming mode: Input area stays at tracked position.
423
+ * Content streams above the input area.
424
+ * After streaming ends, update input area position.
203
425
  */
204
426
  setMode(mode) {
205
427
  const prevMode = this.mode;
206
428
  this.mode = mode;
207
429
  if (mode === 'streaming' && prevMode !== 'streaming') {
208
- // Keep scroll region active so status/prompt stay pinned while streaming
209
- this.resetStreamingRenderThrottle();
210
- this.enableScrollRegion();
430
+ // Track streaming start time for elapsed display
431
+ this.streamingStartTime = Date.now();
432
+ // Ensure unified UI is initialized (if not already done on launch)
433
+ if (!this.unifiedUIInitialized) {
434
+ this.initializeUnifiedUI();
435
+ }
436
+ // Clear the input area visual before streaming starts
437
+ // Content will stream where the input area was
438
+ this.clearInputAreaVisual();
211
439
  this.renderDirty = true;
212
- this.render();
213
440
  }
214
441
  else if (mode !== 'streaming' && prevMode === 'streaming') {
215
- // Streaming ended - render the input area
216
- this.resetStreamingRenderThrottle();
217
- this.enableScrollRegion();
218
- this.forceRender();
442
+ // Stop streaming render timer (if any)
443
+ if (this.streamingRenderTimer) {
444
+ clearInterval(this.streamingRenderTimer);
445
+ this.streamingRenderTimer = null;
446
+ }
447
+ // Reset streaming time
448
+ this.streamingStartTime = null;
449
+ // Reset flow mode tracking
450
+ this.flowModeRenderedLines = 0;
451
+ // Add spacing after streamed content
452
+ this.write('\n\n');
453
+ // Update input area position to after the new content
454
+ // Estimate new row based on terminal state
455
+ this.inputAreaStartRow = this.estimateCurrentRow();
456
+ // Render input area at new position
457
+ writeLock.withLock(() => {
458
+ this.renderFloatingInputArea();
459
+ }, 'terminalInput.streamingEnd');
460
+ }
461
+ }
462
+ /**
463
+ * Clear the input area visual (before streaming replaces it with content)
464
+ */
465
+ clearInputAreaVisual() {
466
+ const startRow = this.inputAreaStartRow;
467
+ // Clear the lines where input area was
468
+ for (let i = 0; i < this.reservedLines; i++) {
469
+ this.write(ESC.TO(startRow + i, 1));
470
+ this.write(ESC.CLEAR_LINE);
219
471
  }
472
+ // Position cursor at start for streaming content
473
+ this.write(ESC.TO(startRow, 1));
474
+ }
475
+ /**
476
+ * Estimate current cursor row after streaming
477
+ */
478
+ estimateCurrentRow() {
479
+ // This is approximate - we track based on inputAreaStartRow
480
+ // After streaming, content has replaced the input area and possibly more
481
+ // For now, assume content added some lines
482
+ return this.inputAreaStartRow + 5; // Rough estimate
483
+ }
484
+ /**
485
+ * Enable or disable flow mode.
486
+ * In flow mode, the input renders immediately after content (wherever cursor is).
487
+ * When disabled, input renders at the absolute bottom of terminal.
488
+ */
489
+ setFlowMode(enabled) {
490
+ if (this.flowMode === enabled)
491
+ return;
492
+ this.flowMode = enabled;
493
+ this.renderDirty = true;
494
+ this.scheduleRender();
220
495
  }
221
496
  /**
222
- * Keep the top N rows pinned outside the scroll region (used for the launch banner).
497
+ * Check if flow mode is enabled.
498
+ */
499
+ isFlowMode() {
500
+ return this.flowMode;
501
+ }
502
+ /**
503
+ * Set the row where content ends (for idle mode positioning).
504
+ * Input area will render starting from this row + 1.
505
+ */
506
+ setContentEndRow(row) {
507
+ this.contentEndRow = Math.max(0, row);
508
+ this.renderDirty = true;
509
+ this.scheduleRender();
510
+ }
511
+ /**
512
+ * Set available slash commands for auto-complete suggestions.
513
+ */
514
+ setCommands(commands) {
515
+ this.commandSuggestions = commands;
516
+ this.updateSuggestions();
517
+ }
518
+ /**
519
+ * Update filtered suggestions based on current input.
520
+ */
521
+ updateSuggestions() {
522
+ const input = this.buffer.trim();
523
+ // Only show suggestions when input starts with "/"
524
+ if (!input.startsWith('/')) {
525
+ this.showSuggestions = false;
526
+ this.filteredSuggestions = [];
527
+ this.selectedSuggestionIndex = 0;
528
+ return;
529
+ }
530
+ const query = input.toLowerCase();
531
+ this.filteredSuggestions = this.commandSuggestions.filter(cmd => cmd.command.toLowerCase().startsWith(query) ||
532
+ cmd.command.toLowerCase().includes(query.slice(1)));
533
+ // Show suggestions if we have matches
534
+ this.showSuggestions = this.filteredSuggestions.length > 0;
535
+ // Keep selection in bounds
536
+ if (this.selectedSuggestionIndex >= this.filteredSuggestions.length) {
537
+ this.selectedSuggestionIndex = Math.max(0, this.filteredSuggestions.length - 1);
538
+ }
539
+ }
540
+ /**
541
+ * Select next suggestion (arrow down / tab).
542
+ */
543
+ selectNextSuggestion() {
544
+ if (!this.showSuggestions || this.filteredSuggestions.length === 0)
545
+ return;
546
+ this.selectedSuggestionIndex = (this.selectedSuggestionIndex + 1) % this.filteredSuggestions.length;
547
+ this.renderDirty = true;
548
+ this.scheduleRender();
549
+ }
550
+ /**
551
+ * Select previous suggestion (arrow up / shift+tab).
552
+ */
553
+ selectPrevSuggestion() {
554
+ if (!this.showSuggestions || this.filteredSuggestions.length === 0)
555
+ return;
556
+ this.selectedSuggestionIndex = this.selectedSuggestionIndex === 0
557
+ ? this.filteredSuggestions.length - 1
558
+ : this.selectedSuggestionIndex - 1;
559
+ this.renderDirty = true;
560
+ this.scheduleRender();
561
+ }
562
+ /**
563
+ * Accept current suggestion and insert into buffer.
564
+ */
565
+ acceptSuggestion() {
566
+ if (!this.showSuggestions || this.filteredSuggestions.length === 0)
567
+ return false;
568
+ const selected = this.filteredSuggestions[this.selectedSuggestionIndex];
569
+ if (!selected)
570
+ return false;
571
+ // Replace buffer with selected command
572
+ this.buffer = selected.command + ' ';
573
+ this.cursor = this.buffer.length;
574
+ this.showSuggestions = false;
575
+ this.renderDirty = true;
576
+ this.scheduleRender();
577
+ return true;
578
+ }
579
+ /**
580
+ * Check if suggestions are visible.
581
+ */
582
+ areSuggestionsVisible() {
583
+ return this.showSuggestions && this.filteredSuggestions.length > 0;
584
+ }
585
+ /**
586
+ * Update token count for metrics display
587
+ */
588
+ setTokensUsed(tokens) {
589
+ this.tokensUsed = tokens;
590
+ }
591
+ /**
592
+ * Toggle thinking/reasoning mode
593
+ */
594
+ toggleThinking() {
595
+ this.thinkingEnabled = !this.thinkingEnabled;
596
+ this.emit('thinkingToggle', this.thinkingEnabled);
597
+ this.scheduleRender();
598
+ }
599
+ /**
600
+ * Get thinking enabled state
601
+ */
602
+ isThinkingEnabled() {
603
+ return this.thinkingEnabled;
604
+ }
605
+ /**
606
+ * Keep the top N rows pinned (used for the launch banner tracking).
607
+ * Note: No longer uses scroll regions - inline rendering only.
223
608
  */
224
609
  setPinnedHeaderLines(count) {
225
- // No pinned header rows anymore; keep everything in the scroll region.
226
- if (this.pinnedTopRows !== 0) {
227
- this.pinnedTopRows = 0;
228
- if (this.scrollRegionActive) {
229
- this.applyScrollRegion();
230
- }
610
+ this.pinnedTopRows = count;
611
+ }
612
+ /**
613
+ * Anchor prompt rendering near a specific row (inline layout). Pass null to
614
+ * restore the default bottom-aligned layout.
615
+ */
616
+ setInlineAnchor(row) {
617
+ if (row === null || row === undefined) {
618
+ this.inlineAnchorRow = null;
619
+ this.inlineLayout = false;
620
+ this.renderDirty = true;
621
+ this.render();
622
+ return;
231
623
  }
624
+ const { rows } = this.getSize();
625
+ const clamped = Math.max(1, Math.min(Math.floor(row), rows));
626
+ this.inlineAnchorRow = clamped;
627
+ this.inlineLayout = true;
628
+ this.renderDirty = true;
629
+ this.render();
630
+ }
631
+ /**
632
+ * Provide a dynamic anchor callback. When set, the prompt will follow the
633
+ * output by re-evaluating the anchor before each render.
634
+ */
635
+ setInlineAnchorProvider(provider) {
636
+ this.anchorProvider = provider;
637
+ if (!provider) {
638
+ this.inlineLayout = false;
639
+ this.inlineAnchorRow = null;
640
+ this.renderDirty = true;
641
+ this.render();
642
+ return;
643
+ }
644
+ this.inlineLayout = true;
645
+ this.renderDirty = true;
646
+ this.render();
232
647
  }
233
648
  /**
234
649
  * Get current mode
@@ -261,14 +676,17 @@ export class TerminalInput extends EventEmitter {
261
676
  }
262
677
  /**
263
678
  * Clear the buffer
679
+ * @param skipRender - If true, don't trigger a re-render (used during submit flow)
264
680
  */
265
- clear() {
681
+ clear(skipRender = false) {
266
682
  this.buffer = '';
267
683
  this.cursor = 0;
268
684
  this.historyIndex = -1;
269
685
  this.tempInput = '';
270
686
  this.pastePlaceholders = [];
271
- this.scheduleRender();
687
+ if (!skipRender) {
688
+ this.scheduleRender();
689
+ }
272
690
  }
273
691
  /**
274
692
  * Get queued inputs
@@ -339,37 +757,6 @@ export class TerminalInput extends EventEmitter {
339
757
  this.streamingLabel = next;
340
758
  this.scheduleRender();
341
759
  }
342
- /**
343
- * Surface meta status just above the divider (e.g., elapsed time or token usage).
344
- */
345
- setMetaStatus(meta) {
346
- const nextElapsed = typeof meta.elapsedSeconds === 'number' && Number.isFinite(meta.elapsedSeconds) && meta.elapsedSeconds >= 0
347
- ? Math.floor(meta.elapsedSeconds)
348
- : null;
349
- const nextTokens = typeof meta.tokensUsed === 'number' && Number.isFinite(meta.tokensUsed) && meta.tokensUsed >= 0
350
- ? Math.floor(meta.tokensUsed)
351
- : null;
352
- const nextLimit = typeof meta.tokenLimit === 'number' && Number.isFinite(meta.tokenLimit) && meta.tokenLimit > 0
353
- ? Math.floor(meta.tokenLimit)
354
- : null;
355
- const nextThinking = typeof meta.thinkingMs === 'number' && Number.isFinite(meta.thinkingMs) && meta.thinkingMs >= 0
356
- ? Math.floor(meta.thinkingMs)
357
- : null;
358
- const nextThinkingHasContent = !!meta.thinkingHasContent;
359
- if (this.metaElapsedSeconds === nextElapsed &&
360
- this.metaTokensUsed === nextTokens &&
361
- this.metaTokenLimit === nextLimit &&
362
- this.metaThinkingMs === nextThinking &&
363
- this.metaThinkingHasContent === nextThinkingHasContent) {
364
- return;
365
- }
366
- this.metaElapsedSeconds = nextElapsed;
367
- this.metaTokensUsed = nextTokens;
368
- this.metaTokenLimit = nextLimit;
369
- this.metaThinkingMs = nextThinking;
370
- this.metaThinkingHasContent = nextThinkingHasContent;
371
- this.scheduleRender();
372
- }
373
760
  /**
374
761
  * Keep mode toggles (verification/auto-continue) visible in the control bar.
375
762
  * Hotkey labels remain stable so the bar looks the same before/during streaming.
@@ -379,22 +766,26 @@ export class TerminalInput extends EventEmitter {
379
766
  const nextAutoContinue = !!options.autoContinueEnabled;
380
767
  const nextVerifyHotkey = options.verificationHotkey ?? this.verificationHotkey;
381
768
  const nextAutoHotkey = options.autoContinueHotkey ?? this.autoContinueHotkey;
382
- const nextThinkingHotkey = options.thinkingHotkey ?? this.thinkingHotkey;
383
- const nextThinkingLabel = options.thinkingModeLabel === undefined ? this.thinkingModeLabel : (options.thinkingModeLabel || null);
384
769
  if (this.verificationEnabled === nextVerification &&
385
770
  this.autoContinueEnabled === nextAutoContinue &&
386
771
  this.verificationHotkey === nextVerifyHotkey &&
387
- this.autoContinueHotkey === nextAutoHotkey &&
388
- this.thinkingHotkey === nextThinkingHotkey &&
389
- this.thinkingModeLabel === nextThinkingLabel) {
772
+ this.autoContinueHotkey === nextAutoHotkey) {
390
773
  return;
391
774
  }
392
775
  this.verificationEnabled = nextVerification;
393
776
  this.autoContinueEnabled = nextAutoContinue;
394
777
  this.verificationHotkey = nextVerifyHotkey;
395
778
  this.autoContinueHotkey = nextAutoHotkey;
396
- this.thinkingHotkey = nextThinkingHotkey;
397
- this.thinkingModeLabel = nextThinkingLabel;
779
+ this.scheduleRender();
780
+ }
781
+ /**
782
+ * Set the model info string (e.g., "OpenAI · gpt-4")
783
+ * This is displayed persistently above the input area.
784
+ */
785
+ setModelInfo(info) {
786
+ if (this.modelInfo === info)
787
+ return;
788
+ this.modelInfo = info;
398
789
  this.scheduleRender();
399
790
  }
400
791
  /**
@@ -407,161 +798,34 @@ export class TerminalInput extends EventEmitter {
407
798
  this.scheduleRender();
408
799
  }
409
800
  /**
410
- * Surface model/provider context in the controls bar.
411
- */
412
- setModelContext(options) {
413
- const nextModel = options.model?.trim() || null;
414
- const nextProvider = options.provider?.trim() || null;
415
- if (this.modelLabel === nextModel && this.providerLabel === nextProvider) {
416
- return;
417
- }
418
- this.modelLabel = nextModel;
419
- this.providerLabel = nextProvider;
420
- this.scheduleRender();
421
- }
422
- /**
423
- * Render the input area - Claude Code style with mode controls
801
+ * Render the input area
424
802
  *
425
- * During streaming we keep the scroll region active and repaint only the
426
- * pinned status/input block (throttled) so streamed content can scroll
427
- * naturally above while elapsed time and status stay fresh.
803
+ * Uses floating input area that updates in place at tracked position.
804
+ * During streaming, still renders to keep UI responsive.
428
805
  */
429
806
  render() {
430
807
  if (!this.canRender())
431
808
  return;
432
809
  if (this.isRendering)
433
810
  return;
434
- const streamingActive = this.mode === 'streaming' || isStreamingMode();
435
- // During streaming we still render the pinned input/status region, but throttle
436
- // to avoid fighting with the streamed content flow.
437
- if (streamingActive && this.lastStreamingRender > 0) {
438
- const elapsed = Date.now() - this.lastStreamingRender;
439
- const waitMs = Math.max(0, this.streamingRenderInterval - elapsed);
440
- if (waitMs > 0) {
441
- this.renderDirty = true;
442
- this.scheduleStreamingRender(waitMs);
443
- return;
444
- }
445
- }
446
811
  const shouldSkip = !this.renderDirty &&
447
812
  this.buffer === this.lastRenderContent &&
448
813
  this.cursor === this.lastRenderCursor;
449
814
  this.renderDirty = false;
450
- // Skip if nothing changed and no explicit refresh requested
815
+ // Skip if nothing changed (unless explicitly forced)
451
816
  if (shouldSkip) {
452
817
  return;
453
818
  }
454
- // If write lock is held, defer render to avoid race conditions
819
+ // If write lock is held, defer render
455
820
  if (writeLock.isLocked()) {
456
821
  writeLock.safeWrite(() => this.render());
457
822
  return;
458
823
  }
459
- const performRender = () => {
460
- if (!this.scrollRegionActive) {
461
- this.enableScrollRegion();
462
- }
463
- const { rows, cols } = this.getSize();
464
- const maxWidth = Math.max(8, cols - 4);
465
- // Wrap buffer into display lines
466
- const { lines, cursorLine, cursorCol } = this.wrapBuffer(maxWidth);
467
- const availableForContent = Math.max(1, rows - 3); // room for separator + input + controls
468
- const maxVisible = Math.max(1, Math.min(this.config.maxLines, availableForContent));
469
- const displayLines = Math.min(lines.length, maxVisible);
470
- const metaLines = this.buildMetaLines(cols - 2);
471
- // Reserved lines: optional meta lines + separator(1) + input lines + controls(1)
472
- this.updateReservedLines(displayLines + 2 + metaLines.length);
473
- // Calculate display window (keep cursor visible)
474
- let startLine = 0;
475
- if (lines.length > displayLines) {
476
- startLine = Math.max(0, cursorLine - displayLines + 1);
477
- startLine = Math.min(startLine, lines.length - displayLines);
478
- }
479
- const visibleLines = lines.slice(startLine, startLine + displayLines);
480
- const adjustedCursorLine = cursorLine - startLine;
481
- // Hide cursor during render to prevent flicker
482
- this.write(ESC.HIDE);
483
- this.write(ESC.RESET);
484
- const startRow = Math.max(1, rows - this.reservedLines + 1);
485
- let currentRow = startRow;
486
- // Clear the reserved block to avoid stale meta/status lines
487
- this.clearReservedArea(startRow, this.reservedLines, cols);
488
- // Meta/status header (elapsed, tokens/context)
489
- for (const metaLine of metaLines) {
490
- this.write(ESC.TO(currentRow, 1));
491
- this.write(ESC.CLEAR_LINE);
492
- this.write(metaLine);
493
- currentRow += 1;
494
- }
495
- // Separator line
496
- this.write(ESC.TO(currentRow, 1));
497
- this.write(ESC.CLEAR_LINE);
498
- const divider = renderDivider(cols - 2);
499
- this.write(divider);
500
- currentRow += 1;
501
- // Render input lines
502
- let finalRow = currentRow;
503
- let finalCol = 3;
504
- for (let i = 0; i < visibleLines.length; i++) {
505
- const rowNum = currentRow + i;
506
- this.write(ESC.TO(rowNum, 1));
507
- this.write(ESC.CLEAR_LINE);
508
- const line = visibleLines[i] ?? '';
509
- const absoluteLineIdx = startLine + i;
510
- const isFirstLine = absoluteLineIdx === 0;
511
- const isCursorLine = i === adjustedCursorLine;
512
- // Background
513
- this.write(ESC.BG_DARK);
514
- // Prompt prefix
515
- this.write(ESC.DIM);
516
- this.write(isFirstLine ? this.config.promptChar : this.config.continuationChar);
517
- this.write(ESC.RESET);
518
- this.write(ESC.BG_DARK);
519
- if (isCursorLine) {
520
- // Render with block cursor
521
- const col = Math.min(cursorCol, line.length);
522
- const before = line.slice(0, col);
523
- const at = col < line.length ? line[col] : ' ';
524
- const after = col < line.length ? line.slice(col + 1) : '';
525
- this.write(before);
526
- this.write(ESC.REVERSE + ESC.BOLD);
527
- this.write(at);
528
- this.write(ESC.RESET + ESC.BG_DARK);
529
- this.write(after);
530
- finalRow = rowNum;
531
- finalCol = this.config.promptChar.length + col + 1;
532
- }
533
- else {
534
- this.write(line);
535
- }
536
- // Pad to edge for clean look
537
- const lineLen = this.config.promptChar.length + line.length + (isCursorLine && cursorCol >= line.length ? 1 : 0);
538
- const padding = Math.max(0, cols - lineLen - 1);
539
- if (padding > 0)
540
- this.write(' '.repeat(padding));
541
- this.write(ESC.RESET);
542
- }
543
- // Mode controls line (Claude Code style)
544
- const controlRow = currentRow + visibleLines.length;
545
- this.write(ESC.TO(controlRow, 1));
546
- this.write(ESC.CLEAR_LINE);
547
- this.write(this.buildModeControls(cols));
548
- // Position cursor in the input box for user editing
549
- this.write(ESC.TO(finalRow, Math.min(finalCol, cols)));
550
- this.write(ESC.SHOW);
551
- // Update state
552
- this.lastRenderContent = this.buffer;
553
- this.lastRenderCursor = this.cursor;
554
- this.lastStreamingRender = streamingActive ? Date.now() : 0;
555
- if (this.streamingRenderTimer) {
556
- clearTimeout(this.streamingRenderTimer);
557
- this.streamingRenderTimer = null;
558
- }
559
- };
560
- // Use write lock during render to prevent interleaved output
561
- writeLock.lock('terminalInput.render');
562
824
  this.isRendering = true;
825
+ writeLock.lock('terminalInput.render');
563
826
  try {
564
- performRender();
827
+ // Render floating input area at tracked position
828
+ this.renderFloatingInputArea();
565
829
  }
566
830
  finally {
567
831
  writeLock.unlock();
@@ -569,228 +833,99 @@ export class TerminalInput extends EventEmitter {
569
833
  }
570
834
  }
571
835
  /**
572
- * Build one or more compact meta lines above the divider (thinking, status, usage).
573
- * During streaming, shows model line pinned above streaming info.
836
+ * Build status bar showing streaming/ready status and key info.
837
+ * This is the TOP line above the input area - minimal Claude Code style.
574
838
  */
575
- buildMetaLines(width) {
576
- const streamingActive = this.mode === 'streaming' || isStreamingMode();
577
- const lines = [];
578
- // Model line should ALWAYS be shown (pinned above streaming content)
579
- if (this.modelLabel) {
580
- const modelText = this.providerLabel
581
- ? `model ${this.modelLabel} @ ${this.providerLabel}`
582
- : `model ${this.modelLabel}`;
583
- lines.push(renderStatusLine([{ text: modelText, tone: 'info' }], width));
584
- }
585
- // During streaming, add a compact status line with essential info
586
- if (streamingActive) {
587
- const parts = [];
588
- // Essential streaming info
589
- if (this.metaThinkingMs !== null) {
590
- parts.push({ text: formatThinking(this.metaThinkingMs, this.metaThinkingHasContent), tone: 'info' });
591
- }
592
- if (this.metaElapsedSeconds !== null) {
593
- parts.push({ text: this.formatElapsedLabel(this.metaElapsedSeconds), tone: 'muted' });
594
- }
595
- parts.push({ text: 'esc to stop', tone: 'warn' });
596
- if (parts.length) {
597
- lines.push(renderStatusLine(parts, width));
839
+ buildStatusBar(cols) {
840
+ const maxWidth = cols - 2;
841
+ const parts = [];
842
+ // Streaming status with elapsed time (left side)
843
+ if (this.mode === 'streaming') {
844
+ let statusText = '● Streaming';
845
+ if (this.streamingStartTime) {
846
+ const elapsed = Math.floor((Date.now() - this.streamingStartTime) / 1000);
847
+ const mins = Math.floor(elapsed / 60);
848
+ const secs = elapsed % 60;
849
+ statusText += mins > 0 ? ` ${mins}m ${secs}s` : ` ${secs}s`;
598
850
  }
599
- return lines;
600
- }
601
- // Non-streaming: show full status info (model line already added above)
602
- if (this.metaThinkingMs !== null) {
603
- const thinkingText = formatThinking(this.metaThinkingMs, this.metaThinkingHasContent);
604
- lines.push(renderStatusLine([{ text: thinkingText, tone: 'info' }], width));
605
- }
606
- const statusParts = [];
607
- const statusLabel = this.statusMessage ?? this.streamingLabel;
608
- if (statusLabel) {
609
- statusParts.push({ text: statusLabel, tone: 'info' });
851
+ parts.push(`\x1b[32m${statusText}\x1b[0m`); // Green
610
852
  }
611
- if (this.metaElapsedSeconds !== null) {
612
- statusParts.push({ text: this.formatElapsedLabel(this.metaElapsedSeconds), tone: 'muted' });
853
+ // Queue indicator during streaming
854
+ if (this.mode === 'streaming' && this.queue.length > 0) {
855
+ parts.push(`\x1b[36mqueued: ${this.queue.length}\x1b[0m`); // Cyan
613
856
  }
614
- const tokensRemaining = this.computeTokensRemaining();
615
- if (tokensRemaining !== null) {
616
- statusParts.push({ text: `↓ ${tokensRemaining}`, tone: 'muted' });
617
- }
618
- if (statusParts.length) {
619
- lines.push(renderStatusLine(statusParts, width));
620
- }
621
- const usageParts = [];
622
- if (this.metaTokensUsed !== null) {
623
- const formattedUsed = this.formatTokenCount(this.metaTokensUsed);
624
- const formattedLimit = this.metaTokenLimit ? `/${this.formatTokenCount(this.metaTokenLimit)}` : '';
625
- usageParts.push({ text: `tokens ${formattedUsed}${formattedLimit}`, tone: 'muted' });
857
+ // Paste indicator
858
+ if (this.pastePlaceholders.length > 0) {
859
+ const totalLines = this.pastePlaceholders.reduce((sum, p) => sum + p.lineCount, 0);
860
+ parts.push(`\x1b[36m📋 ${totalLines}L\x1b[0m`);
626
861
  }
627
- if (this.contextUsage !== null) {
628
- const tone = this.contextUsage >= 75 ? 'warn' : 'muted';
629
- const left = Math.max(0, 100 - this.contextUsage);
630
- usageParts.push({ text: `context used ${this.contextUsage}% (${left}% left)`, tone });
862
+ // Override/warning status
863
+ if (this.overrideStatusMessage) {
864
+ parts.push(`\x1b[33m⚠ ${this.overrideStatusMessage}\x1b[0m`); // Yellow
631
865
  }
632
- if (this.queue.length > 0) {
633
- usageParts.push({ text: `queued ${this.queue.length}`, tone: 'info' });
866
+ // If idle with empty buffer, show quick shortcuts
867
+ if (this.mode !== 'streaming' && this.buffer.length === 0 && parts.length === 0) {
868
+ return `${ESC.DIM}Type a message or / for commands${ESC.RESET}`;
634
869
  }
635
- if (usageParts.length) {
636
- lines.push(renderStatusLine(usageParts, width));
870
+ // Multi-line indicator
871
+ if (this.buffer.includes('\n')) {
872
+ parts.push(`${ESC.DIM}${this.buffer.split('\n').length}L${ESC.RESET}`);
637
873
  }
638
- return lines;
639
- }
640
- /**
641
- * Clear the reserved bottom block (meta + divider + input + controls) before repainting.
642
- */
643
- clearReservedArea(startRow, reservedLines, cols) {
644
- const width = Math.max(1, cols);
645
- for (let i = 0; i < reservedLines; i++) {
646
- const row = startRow + i;
647
- this.write(ESC.TO(row, 1));
648
- this.write(' '.repeat(width));
874
+ if (parts.length === 0) {
875
+ return ''; // Empty status bar when idle
649
876
  }
877
+ const joined = parts.join(`${ESC.DIM} · ${ESC.RESET}`);
878
+ return joined.slice(0, maxWidth);
650
879
  }
651
880
  /**
652
- * Build Claude Code style mode controls line.
653
- * Combines streaming label + override status + main status for simultaneous display.
881
+ * Build mode controls line showing toggles and context info.
882
+ * This is the BOTTOM line below the input area - Claude Code style layout with erosolar features.
883
+ *
884
+ * Layout: [toggles on left] ... [context info on right]
654
885
  */
655
886
  buildModeControls(cols) {
656
- const width = Math.max(8, cols - 2);
657
- const leftParts = [];
658
- const rightParts = [];
659
- if (this.streamingLabel) {
660
- leftParts.push({ text: `◐ ${this.streamingLabel}`, tone: 'info' });
661
- }
662
- if (this.overrideStatusMessage) {
663
- leftParts.push({ text: `⚠ ${this.overrideStatusMessage}`, tone: 'warn' });
664
- }
665
- if (this.statusMessage) {
666
- leftParts.push({ text: this.statusMessage, tone: this.streamingLabel ? 'muted' : 'info' });
667
- }
668
- const editHotkey = this.formatHotkey('shift+tab');
669
- const editLabel = this.editMode === 'display-edits' ? 'edits: accept' : 'edits: ask';
670
- const editTone = this.editMode === 'display-edits' ? 'success' : 'muted';
671
- leftParts.push({ text: `${editHotkey} ${editLabel}`, tone: editTone });
672
- const verifyHotkey = this.formatHotkey(this.verificationHotkey);
673
- const verifyLabel = this.verificationEnabled ? 'verify:on' : 'verify:off';
674
- leftParts.push({ text: `${verifyHotkey} ${verifyLabel}`, tone: this.verificationEnabled ? 'success' : 'muted' });
675
- const continueHotkey = this.formatHotkey(this.autoContinueHotkey);
676
- const continueLabel = this.autoContinueEnabled ? 'auto:on' : 'auto:off';
677
- leftParts.push({ text: `${continueHotkey} ${continueLabel}`, tone: this.autoContinueEnabled ? 'info' : 'muted' });
678
- if (this.queue.length > 0 && this.mode !== 'streaming') {
679
- leftParts.push({ text: `queued ${this.queue.length}`, tone: 'info' });
680
- }
681
- if (this.buffer.includes('\n')) {
682
- const lineCount = this.buffer.split('\n').length;
683
- leftParts.push({ text: `${lineCount} lines`, tone: 'muted' });
684
- }
685
- if (this.pastePlaceholders.length > 0) {
686
- const latest = this.pastePlaceholders[this.pastePlaceholders.length - 1];
687
- leftParts.push({
688
- text: `paste #${latest.id} +${latest.lineCount} lines (⌫ to drop)`,
689
- tone: 'info',
690
- });
691
- }
692
- const contextRemaining = this.computeContextRemaining();
693
- if (this.thinkingModeLabel) {
694
- const thinkingHotkey = this.formatHotkey(this.thinkingHotkey);
695
- rightParts.push({ text: `${thinkingHotkey} thinking:${this.thinkingModeLabel}`, tone: 'info' });
696
- }
697
- // Show model in controls only when NOT streaming (during streaming it's in meta lines)
698
- const streamingActive = this.mode === 'streaming' || isStreamingMode();
699
- if (this.modelLabel && !streamingActive) {
700
- const modelText = this.providerLabel ? `${this.modelLabel} @ ${this.providerLabel}` : this.modelLabel;
701
- rightParts.push({ text: modelText, tone: 'muted' });
702
- }
703
- if (contextRemaining !== null) {
704
- const tone = contextRemaining <= 10 ? 'warn' : 'muted';
705
- const label = contextRemaining === 0 && this.contextUsage !== null
706
- ? 'Context auto-compact imminent'
707
- : `Context left until auto-compact: ${contextRemaining}%`;
708
- rightParts.push({ text: label, tone });
709
- }
710
- if (!rightParts.length || width < 60) {
711
- const merged = rightParts.length ? [...leftParts, ...rightParts] : leftParts;
712
- return renderStatusLine(merged, width);
713
- }
714
- const leftWidth = Math.max(12, Math.floor(width * 0.6));
715
- const rightWidth = Math.max(14, width - leftWidth - 1);
716
- const leftText = renderStatusLine(leftParts, leftWidth);
717
- const rightText = renderStatusLine(rightParts, rightWidth);
718
- const spacing = Math.max(1, width - this.visibleLength(leftText) - this.visibleLength(rightText));
719
- return `${leftText}${' '.repeat(spacing)}${rightText}`;
720
- }
721
- formatHotkey(hotkey) {
722
- const normalized = hotkey.trim().toLowerCase();
723
- if (!normalized)
724
- return hotkey;
725
- const parts = normalized.split('+').filter(Boolean);
726
- const map = {
727
- shift: '⇧',
728
- sh: '⇧',
729
- alt: '⌥',
730
- option: '⌥',
731
- opt: '⌥',
732
- ctrl: '⌃',
733
- control: '⌃',
734
- cmd: '⌘',
735
- meta: '⌘',
736
- };
737
- const formatted = parts
738
- .map((part) => {
739
- const symbol = map[part];
740
- if (symbol)
741
- return symbol;
742
- return part.length === 1 ? part.toUpperCase() : part.toUpperCase();
743
- })
744
- .join('');
745
- return formatted || hotkey;
746
- }
747
- computeContextRemaining() {
748
- if (this.contextUsage === null) {
749
- return null;
750
- }
751
- return Math.max(0, this.contextAutoCompactThreshold - this.contextUsage);
752
- }
753
- computeTokensRemaining() {
754
- if (this.metaTokensUsed === null || this.metaTokenLimit === null) {
755
- return null;
756
- }
757
- const remaining = Math.max(0, this.metaTokenLimit - this.metaTokensUsed);
758
- return this.formatTokenCount(remaining);
759
- }
760
- formatElapsedLabel(seconds) {
761
- if (seconds < 60) {
762
- return `${seconds}s`;
887
+ const maxWidth = cols - 2;
888
+ // Use schema-defined colors for consistency
889
+ const { green: GREEN, yellow: YELLOW, cyan: CYAN, magenta: MAGENTA, red: RED, dim: DIM, reset: R } = UI_COLORS;
890
+ // Mode toggles with colors (following ModeControlsSchema)
891
+ const toggles = [];
892
+ // Edit mode (green=auto, yellow=ask) - per schema.editMode
893
+ if (this.editMode === 'display-edits') {
894
+ toggles.push(`${GREEN}⏵⏵ auto-edit${R}`);
763
895
  }
764
- const mins = Math.floor(seconds / 60);
765
- const secs = seconds % 60;
766
- return secs > 0 ? `${mins}m ${secs}s` : `${mins}m`;
767
- }
768
- formatTokenCount(value) {
769
- if (!Number.isFinite(value)) {
770
- return `${value}`;
771
- }
772
- if (value >= 1_000_000) {
773
- return `${(value / 1_000_000).toFixed(1)}M`;
774
- }
775
- if (value >= 1_000) {
776
- return `${(value / 1_000).toFixed(1)}k`;
777
- }
778
- return `${Math.round(value)}`;
779
- }
780
- visibleLength(value) {
781
- const ansiPattern = /\u001B\[[0-?]*[ -/]*[@-~]/g;
782
- return value.replace(ansiPattern, '').length;
783
- }
784
- /**
785
- * Debug-only snapshot used by tests to assert rendered strings without
786
- * needing a TTY. Not used by production code.
787
- */
788
- getDebugUiSnapshot(width) {
789
- const cols = Math.max(8, width ?? this.getSize().cols);
790
- return {
791
- meta: this.buildMetaLines(cols - 2),
792
- controls: this.buildModeControls(cols),
793
- };
896
+ else {
897
+ toggles.push(`${YELLOW}⏸⏸ ask-first${R}`);
898
+ }
899
+ // Thinking mode (cyan when on) - per schema.thinkingMode
900
+ toggles.push(this.thinkingEnabled ? `${CYAN}💭 think${R}` : `${DIM}○ think${R}`);
901
+ // Verification (green when on) - per schema.verificationMode
902
+ toggles.push(this.verificationEnabled ? `${GREEN}✓ verify${R}` : `${DIM}○ verify${R}`);
903
+ // Auto-continue (magenta when on) - per schema.autoContinueMode
904
+ toggles.push(this.autoContinueEnabled ? `${MAGENTA}↻ auto${R}` : `${DIM}○ auto${R}`);
905
+ const leftPart = toggles.join(`${DIM} · ${R}`) + `${DIM} (⇧⇥)${R}`;
906
+ // Context usage with color - per schema.contextUsage thresholds
907
+ let rightPart = '';
908
+ if (this.contextUsage !== null) {
909
+ const rem = Math.max(0, 100 - this.contextUsage);
910
+ // Thresholds: critical < 10%, warning < 25%
911
+ if (rem < 10)
912
+ rightPart = `${RED}⚠ ctx: ${rem}%${R}`;
913
+ else if (rem < 25)
914
+ rightPart = `${YELLOW}! ctx: ${rem}%${R}`;
915
+ else
916
+ rightPart = `${DIM}ctx: ${rem}%${R}`;
917
+ }
918
+ // Calculate visible lengths (strip ANSI)
919
+ const strip = (s) => s.replace(/\x1b\[[0-9;]*m/g, '');
920
+ const leftLen = strip(leftPart).length;
921
+ const rightLen = strip(rightPart).length;
922
+ if (leftLen + rightLen < maxWidth - 4) {
923
+ return `${leftPart}${' '.repeat(maxWidth - leftLen - rightLen)}${rightPart}`;
924
+ }
925
+ if (rightLen > 0 && leftLen + 8 < maxWidth) {
926
+ return `${leftPart} ${rightPart}`;
927
+ }
928
+ return leftPart;
794
929
  }
795
930
  /**
796
931
  * Force a re-render
@@ -813,19 +948,17 @@ export class TerminalInput extends EventEmitter {
813
948
  handleResize() {
814
949
  this.lastRenderContent = '';
815
950
  this.lastRenderCursor = -1;
816
- this.resetStreamingRenderThrottle();
817
951
  // Re-clamp pinned header rows to the new terminal height
818
952
  this.setPinnedHeaderLines(this.pinnedTopRows);
819
- if (this.scrollRegionActive) {
820
- this.disableScrollRegion();
821
- this.enableScrollRegion();
822
- }
823
953
  this.scheduleRender();
824
954
  }
825
955
  /**
826
956
  * Register with display's output interceptor to position cursor correctly.
827
957
  * When scroll region is active, output needs to go to the scroll region,
828
958
  * not the protected bottom area where the input is rendered.
959
+ *
960
+ * NOTE: With scroll region properly set, content naturally stays within
961
+ * the region boundaries - no cursor manipulation needed per-write.
829
962
  */
830
963
  registerOutputInterceptor(display) {
831
964
  if (this.outputInterceptorCleanup) {
@@ -833,66 +966,25 @@ export class TerminalInput extends EventEmitter {
833
966
  }
834
967
  this.outputInterceptorCleanup = display.registerOutputInterceptor({
835
968
  beforeWrite: () => {
836
- // Position cursor at current content row (starts at top, moves down).
837
- // When contentRow reaches scrollBottom, terminal handles scrolling.
838
- if (this.scrollRegionActive) {
839
- const { rows } = this.getSize();
840
- const scrollBottom = Math.max(this.pinnedTopRows + 1, rows - this.reservedLines);
841
- const targetRow = Math.min(this.contentRow, scrollBottom);
842
- this.write(ESC.SAVE);
843
- this.write(ESC.TO(targetRow, 1));
844
- }
969
+ // Scroll region handles content containment automatically
970
+ // No per-write cursor manipulation needed
845
971
  },
846
- afterWrite: (content) => {
847
- // Advance content row by number of lines written and restore cursor.
848
- if (this.scrollRegionActive) {
849
- const { rows } = this.getSize();
850
- const scrollBottom = Math.max(this.pinnedTopRows + 1, rows - this.reservedLines);
851
- // Count newlines in content to advance by correct amount
852
- const lineCount = content ? (content.match(/\n/g) || []).length + 1 : 1;
853
- this.contentRow = Math.min(this.contentRow + lineCount, scrollBottom);
854
- this.write(ESC.RESTORE);
855
- }
972
+ afterWrite: () => {
973
+ // No cursor manipulation needed
856
974
  },
857
975
  });
858
976
  }
859
- /**
860
- * Write content directly into the scroll region (for banner, user prompts, etc.).
861
- * Content starts at top and flows down, then scrolls when bottom is reached.
862
- */
863
- writeToScrollRegion(content) {
864
- if (!content)
865
- return;
866
- // Ensure scroll region is active
867
- if (!this.scrollRegionActive) {
868
- this.enableScrollRegion();
869
- }
870
- const { rows } = this.getSize();
871
- const scrollBottom = Math.max(this.pinnedTopRows + 1, rows - this.reservedLines);
872
- const targetRow = Math.min(this.contentRow, scrollBottom);
873
- // Write at current content position
874
- this.write(ESC.SAVE);
875
- this.write(ESC.TO(targetRow, 1));
876
- this.write(content);
877
- this.write(ESC.RESTORE);
878
- // Advance contentRow by number of lines written
879
- const lineCount = (content.match(/\n/g) || []).length + 1;
880
- this.contentRow = Math.min(this.contentRow + lineCount, scrollBottom);
881
- }
882
- /**
883
- * Reset content position to start of scroll region.
884
- * Does NOT clear the terminal - content starts from current position.
885
- */
886
- resetContentPosition() {
887
- const scrollTop = Math.max(1, this.pinnedTopRows + 1);
888
- this.contentRow = scrollTop;
889
- }
890
977
  /**
891
978
  * Dispose and clean up
892
979
  */
893
980
  dispose() {
894
981
  if (this.disposed)
895
982
  return;
983
+ // Clean up streaming render timer
984
+ if (this.streamingRenderTimer) {
985
+ clearInterval(this.streamingRenderTimer);
986
+ this.streamingRenderTimer = null;
987
+ }
896
988
  // Clean up output interceptor
897
989
  if (this.outputInterceptorCleanup) {
898
990
  this.outputInterceptorCleanup();
@@ -900,8 +992,8 @@ export class TerminalInput extends EventEmitter {
900
992
  }
901
993
  this.disposed = true;
902
994
  this.enabled = false;
903
- this.resetStreamingRenderThrottle();
904
- this.disableScrollRegion();
995
+ // Reset scroll region if it was set
996
+ this.write(ESC.RESET_SCROLL);
905
997
  this.disableBracketedPaste();
906
998
  this.buffer = '';
907
999
  this.queue = [];
@@ -1006,7 +1098,22 @@ export class TerminalInput extends EventEmitter {
1006
1098
  this.toggleEditMode();
1007
1099
  return true;
1008
1100
  }
1009
- this.insertText(' ');
1101
+ // Tab: toggle paste expansion if in placeholder, otherwise toggle thinking
1102
+ if (this.findPlaceholderAt(this.cursor)) {
1103
+ this.togglePasteExpansion();
1104
+ }
1105
+ else {
1106
+ this.toggleThinking();
1107
+ }
1108
+ return true;
1109
+ case 'escape':
1110
+ // Esc: interrupt if streaming, otherwise clear buffer
1111
+ if (this.mode === 'streaming') {
1112
+ this.emit('interrupt');
1113
+ }
1114
+ else if (this.buffer.length > 0) {
1115
+ this.clear();
1116
+ }
1010
1117
  return true;
1011
1118
  }
1012
1119
  return false;
@@ -1024,6 +1131,7 @@ export class TerminalInput extends EventEmitter {
1024
1131
  this.insertPlainText(chunk, insertPos);
1025
1132
  this.cursor = insertPos + chunk.length;
1026
1133
  this.emit('change', this.buffer);
1134
+ this.updateSuggestions();
1027
1135
  this.scheduleRender();
1028
1136
  }
1029
1137
  insertNewline() {
@@ -1048,6 +1156,7 @@ export class TerminalInput extends EventEmitter {
1048
1156
  this.cursor = Math.max(0, this.cursor - 1);
1049
1157
  }
1050
1158
  this.emit('change', this.buffer);
1159
+ this.updateSuggestions();
1051
1160
  this.scheduleRender();
1052
1161
  }
1053
1162
  deleteForward() {
@@ -1275,12 +1384,13 @@ export class TerminalInput extends EventEmitter {
1275
1384
  timestamp: Date.now(),
1276
1385
  });
1277
1386
  this.emit('queue', text);
1278
- this.clear(); // Clear immediately for queued input
1387
+ this.clear(); // Clear immediately for queued input, re-render to update queue display
1279
1388
  }
1280
1389
  else {
1281
- // In idle mode, clear the input first, then emit submit.
1282
- // The prompt will be logged as a visible message by the caller.
1283
- this.clear();
1390
+ // In idle mode, clear the input WITHOUT rendering.
1391
+ // The caller will display the user message and start streaming.
1392
+ // We'll render the input area again after streaming ends.
1393
+ this.clear(true); // Skip render - streaming will handle display
1284
1394
  this.emit('submit', text);
1285
1395
  }
1286
1396
  }
@@ -1297,9 +1407,7 @@ export class TerminalInput extends EventEmitter {
1297
1407
  if (available <= 0)
1298
1408
  return;
1299
1409
  const chunk = clean.slice(0, available);
1300
- const isMultiline = isMultilinePaste(chunk);
1301
- const isShortMultiline = isMultiline && this.shouldInlineMultiline(chunk);
1302
- if (isMultiline && !isShortMultiline) {
1410
+ if (isMultilinePaste(chunk)) {
1303
1411
  this.insertPastePlaceholder(chunk);
1304
1412
  }
1305
1413
  else {
@@ -1312,41 +1420,6 @@ export class TerminalInput extends EventEmitter {
1312
1420
  this.scheduleRender();
1313
1421
  }
1314
1422
  // ===========================================================================
1315
- // SCROLL REGION
1316
- // ===========================================================================
1317
- enableScrollRegion() {
1318
- if (this.scrollRegionActive || !this.isTTY())
1319
- return;
1320
- this.applyScrollRegion();
1321
- this.scrollRegionActive = true;
1322
- this.forceRender();
1323
- }
1324
- disableScrollRegion() {
1325
- if (!this.scrollRegionActive)
1326
- return;
1327
- this.write(ESC.RESET_SCROLL);
1328
- this.scrollRegionActive = false;
1329
- }
1330
- applyScrollRegion() {
1331
- const { rows } = this.getSize();
1332
- const scrollTop = Math.max(1, this.pinnedTopRows + 1);
1333
- const scrollBottom = Math.max(scrollTop, rows - this.reservedLines);
1334
- this.write(ESC.SET_SCROLL(scrollTop, scrollBottom));
1335
- }
1336
- updateReservedLines(contentLines) {
1337
- const { rows } = this.getSize();
1338
- const baseLines = 2; // separator + control bar
1339
- const needed = baseLines + contentLines;
1340
- const maxAllowed = Math.max(baseLines, rows - 1 - this.pinnedTopRows);
1341
- const newReserved = Math.min(Math.max(baseLines, needed), maxAllowed);
1342
- if (newReserved !== this.reservedLines) {
1343
- this.reservedLines = newReserved;
1344
- if (this.scrollRegionActive) {
1345
- this.applyScrollRegion();
1346
- }
1347
- }
1348
- }
1349
- // ===========================================================================
1350
1423
  // BUFFER WRAPPING
1351
1424
  // ===========================================================================
1352
1425
  wrapBuffer(maxWidth) {
@@ -1470,19 +1543,17 @@ export class TerminalInput extends EventEmitter {
1470
1543
  this.shiftPlaceholders(position, text.length);
1471
1544
  this.buffer = this.buffer.slice(0, position) + text + this.buffer.slice(position);
1472
1545
  }
1473
- shouldInlineMultiline(content) {
1474
- const lines = content.split('\n').length;
1475
- const maxInlineLines = 4;
1476
- const maxInlineChars = 240;
1477
- return lines <= maxInlineLines && content.length <= maxInlineChars;
1478
- }
1479
1546
  findPlaceholderAt(position) {
1480
1547
  return this.pastePlaceholders.find((ph) => position >= ph.start && position < ph.end) ?? null;
1481
1548
  }
1482
- buildPlaceholder(lineCount) {
1549
+ buildPlaceholder(summary) {
1483
1550
  const id = ++this.pasteCounter;
1484
- const plural = lineCount === 1 ? '' : 's';
1485
- const placeholder = `[Pasted text #${id} +${lineCount} line${plural}]`;
1551
+ const lang = summary.language ? ` ${summary.language.toUpperCase()}` : '';
1552
+ // Show first line preview (truncated)
1553
+ const preview = summary.preview.length > 30
1554
+ ? `${summary.preview.slice(0, 30)}...`
1555
+ : summary.preview;
1556
+ const placeholder = `[📋 #${id}${lang} ${summary.lineCount}L] "${preview}"`;
1486
1557
  return { id, placeholder };
1487
1558
  }
1488
1559
  insertPastePlaceholder(content) {
@@ -1490,21 +1561,67 @@ export class TerminalInput extends EventEmitter {
1490
1561
  if (available <= 0)
1491
1562
  return;
1492
1563
  const cleanContent = content.slice(0, available);
1493
- const lineCount = cleanContent.split('\n').length;
1494
- const { id, placeholder } = this.buildPlaceholder(lineCount);
1564
+ const summary = generatePasteSummary(cleanContent);
1565
+ // For short pastes (< 5 lines), show full content instead of placeholder
1566
+ if (summary.lineCount < 5) {
1567
+ const placeholder = this.findPlaceholderAt(this.cursor);
1568
+ const insertPos = placeholder && this.cursor > placeholder.start ? placeholder.end : this.cursor;
1569
+ this.insertPlainText(cleanContent, insertPos);
1570
+ this.cursor = insertPos + cleanContent.length;
1571
+ return;
1572
+ }
1573
+ const { id, placeholder } = this.buildPlaceholder(summary);
1495
1574
  const insertPos = this.cursor;
1496
1575
  this.shiftPlaceholders(insertPos, placeholder.length);
1497
1576
  this.pastePlaceholders.push({
1498
1577
  id,
1499
1578
  content: cleanContent,
1500
- lineCount,
1579
+ lineCount: summary.lineCount,
1501
1580
  placeholder,
1502
1581
  start: insertPos,
1503
1582
  end: insertPos + placeholder.length,
1583
+ summary,
1584
+ expanded: false,
1504
1585
  });
1505
1586
  this.buffer = this.buffer.slice(0, insertPos) + placeholder + this.buffer.slice(insertPos);
1506
1587
  this.cursor = insertPos + placeholder.length;
1507
1588
  }
1589
+ /**
1590
+ * Toggle expansion of a paste placeholder at the current cursor position.
1591
+ * When expanded, shows first 3 and last 2 lines of the content.
1592
+ */
1593
+ togglePasteExpansion() {
1594
+ const placeholder = this.findPlaceholderAt(this.cursor);
1595
+ if (!placeholder)
1596
+ return false;
1597
+ placeholder.expanded = !placeholder.expanded;
1598
+ // Update the placeholder text in buffer
1599
+ const newPlaceholder = placeholder.expanded
1600
+ ? this.buildExpandedPlaceholder(placeholder)
1601
+ : this.buildPlaceholder(placeholder.summary).placeholder;
1602
+ const lengthDiff = newPlaceholder.length - placeholder.placeholder.length;
1603
+ // Update buffer
1604
+ this.buffer =
1605
+ this.buffer.slice(0, placeholder.start) +
1606
+ newPlaceholder +
1607
+ this.buffer.slice(placeholder.end);
1608
+ // Update placeholder tracking
1609
+ placeholder.placeholder = newPlaceholder;
1610
+ placeholder.end = placeholder.start + newPlaceholder.length;
1611
+ // Shift other placeholders
1612
+ this.shiftPlaceholders(placeholder.end, lengthDiff, placeholder.id);
1613
+ this.scheduleRender();
1614
+ return true;
1615
+ }
1616
+ buildExpandedPlaceholder(ph) {
1617
+ const lines = ph.content.split('\n');
1618
+ const firstLines = lines.slice(0, 3).map(l => l.slice(0, 60)).join('\n');
1619
+ const lastLines = lines.length > 5
1620
+ ? '\n...\n' + lines.slice(-2).map(l => l.slice(0, 60)).join('\n')
1621
+ : '';
1622
+ const lang = ph.summary.language ? ` ${ph.summary.language.toUpperCase()}` : '';
1623
+ return `[📋 #${ph.id}${lang} ${ph.lineCount}L ▼]\n${firstLines}${lastLines}\n[/📋 #${ph.id}]`;
1624
+ }
1508
1625
  deletePlaceholder(placeholder) {
1509
1626
  const length = placeholder.end - placeholder.start;
1510
1627
  this.buffer = this.buffer.slice(0, placeholder.start) + this.buffer.slice(placeholder.end);
@@ -1512,11 +1629,7 @@ export class TerminalInput extends EventEmitter {
1512
1629
  this.shiftPlaceholders(placeholder.end, -length, placeholder.id);
1513
1630
  this.cursor = placeholder.start;
1514
1631
  }
1515
- updateContextUsage(value, autoCompactThreshold) {
1516
- if (typeof autoCompactThreshold === 'number' && Number.isFinite(autoCompactThreshold)) {
1517
- const boundedThreshold = Math.max(1, Math.min(100, Math.round(autoCompactThreshold)));
1518
- this.contextAutoCompactThreshold = boundedThreshold;
1519
- }
1632
+ updateContextUsage(value) {
1520
1633
  if (value === null || !Number.isFinite(value)) {
1521
1634
  this.contextUsage = null;
1522
1635
  }
@@ -1543,22 +1656,6 @@ export class TerminalInput extends EventEmitter {
1543
1656
  const next = this.editMode === 'display-edits' ? 'ask-permission' : 'display-edits';
1544
1657
  this.setEditMode(next);
1545
1658
  }
1546
- scheduleStreamingRender(delayMs) {
1547
- if (this.streamingRenderTimer)
1548
- return;
1549
- const wait = Math.max(16, delayMs);
1550
- this.streamingRenderTimer = setTimeout(() => {
1551
- this.streamingRenderTimer = null;
1552
- this.render();
1553
- }, wait);
1554
- }
1555
- resetStreamingRenderThrottle() {
1556
- if (this.streamingRenderTimer) {
1557
- clearTimeout(this.streamingRenderTimer);
1558
- this.streamingRenderTimer = null;
1559
- }
1560
- this.lastStreamingRender = 0;
1561
- }
1562
1659
  scheduleRender() {
1563
1660
  if (!this.canRender())
1564
1661
  return;