erosolar-cli 1.7.270 → 1.7.271

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