erosolar-cli 1.7.272 → 1.7.273

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