erosolar-cli 1.7.267 → 1.7.269

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 (321) 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/capabilities/agentSpawningCapability.d.ts.map +1 -1
  55. package/dist/capabilities/agentSpawningCapability.js +31 -56
  56. package/dist/capabilities/agentSpawningCapability.js.map +1 -1
  57. package/dist/capabilities/securityTestingCapability.d.ts +13 -0
  58. package/dist/capabilities/securityTestingCapability.d.ts.map +1 -0
  59. package/dist/capabilities/securityTestingCapability.js +25 -0
  60. package/dist/capabilities/securityTestingCapability.js.map +1 -0
  61. package/dist/contracts/agent-schemas.json +15 -0
  62. package/dist/contracts/tools.schema.json +9 -0
  63. package/dist/core/agent.d.ts +2 -2
  64. package/dist/core/agent.d.ts.map +1 -1
  65. package/dist/core/agent.js.map +1 -1
  66. package/dist/core/aiFlowOptimizer.d.ts +26 -0
  67. package/dist/core/aiFlowOptimizer.d.ts.map +1 -0
  68. package/dist/core/aiFlowOptimizer.js +31 -0
  69. package/dist/core/aiFlowOptimizer.js.map +1 -0
  70. package/dist/core/aiOptimizationEngine.d.ts +158 -0
  71. package/dist/core/aiOptimizationEngine.d.ts.map +1 -0
  72. package/dist/core/aiOptimizationEngine.js +428 -0
  73. package/dist/core/aiOptimizationEngine.js.map +1 -0
  74. package/dist/core/aiOptimizationIntegration.d.ts +93 -0
  75. package/dist/core/aiOptimizationIntegration.d.ts.map +1 -0
  76. package/dist/core/aiOptimizationIntegration.js +250 -0
  77. package/dist/core/aiOptimizationIntegration.js.map +1 -0
  78. package/dist/core/customCommands.d.ts +0 -1
  79. package/dist/core/customCommands.d.ts.map +1 -1
  80. package/dist/core/customCommands.js +0 -3
  81. package/dist/core/customCommands.js.map +1 -1
  82. package/dist/core/enhancedErrorRecovery.d.ts +100 -0
  83. package/dist/core/enhancedErrorRecovery.d.ts.map +1 -0
  84. package/dist/core/enhancedErrorRecovery.js +345 -0
  85. package/dist/core/enhancedErrorRecovery.js.map +1 -0
  86. package/dist/core/hooksSystem.d.ts +65 -0
  87. package/dist/core/hooksSystem.d.ts.map +1 -0
  88. package/dist/core/hooksSystem.js +273 -0
  89. package/dist/core/hooksSystem.js.map +1 -0
  90. package/dist/core/memorySystem.d.ts +48 -0
  91. package/dist/core/memorySystem.d.ts.map +1 -0
  92. package/dist/core/memorySystem.js +271 -0
  93. package/dist/core/memorySystem.js.map +1 -0
  94. package/dist/core/toolPreconditions.d.ts.map +1 -1
  95. package/dist/core/toolPreconditions.js +14 -0
  96. package/dist/core/toolPreconditions.js.map +1 -1
  97. package/dist/core/toolRuntime.d.ts +1 -22
  98. package/dist/core/toolRuntime.d.ts.map +1 -1
  99. package/dist/core/toolRuntime.js +5 -0
  100. package/dist/core/toolRuntime.js.map +1 -1
  101. package/dist/core/toolValidation.d.ts.map +1 -1
  102. package/dist/core/toolValidation.js +3 -14
  103. package/dist/core/toolValidation.js.map +1 -1
  104. package/dist/core/unified/errors.d.ts +189 -0
  105. package/dist/core/unified/errors.d.ts.map +1 -0
  106. package/dist/core/unified/errors.js +497 -0
  107. package/dist/core/unified/errors.js.map +1 -0
  108. package/dist/core/unified/index.d.ts +19 -0
  109. package/dist/core/unified/index.d.ts.map +1 -0
  110. package/dist/core/unified/index.js +68 -0
  111. package/dist/core/unified/index.js.map +1 -0
  112. package/dist/core/unified/schema.d.ts +101 -0
  113. package/dist/core/unified/schema.d.ts.map +1 -0
  114. package/dist/core/unified/schema.js +350 -0
  115. package/dist/core/unified/schema.js.map +1 -0
  116. package/dist/core/unified/toolRuntime.d.ts +179 -0
  117. package/dist/core/unified/toolRuntime.d.ts.map +1 -0
  118. package/dist/core/unified/toolRuntime.js +517 -0
  119. package/dist/core/unified/toolRuntime.js.map +1 -0
  120. package/dist/core/unified/tools.d.ts +127 -0
  121. package/dist/core/unified/tools.d.ts.map +1 -0
  122. package/dist/core/unified/tools.js +1333 -0
  123. package/dist/core/unified/tools.js.map +1 -0
  124. package/dist/core/unified/types.d.ts +352 -0
  125. package/dist/core/unified/types.d.ts.map +1 -0
  126. package/dist/core/unified/types.js +12 -0
  127. package/dist/core/unified/types.js.map +1 -0
  128. package/dist/core/unified/version.d.ts +209 -0
  129. package/dist/core/unified/version.d.ts.map +1 -0
  130. package/dist/core/unified/version.js +454 -0
  131. package/dist/core/unified/version.js.map +1 -0
  132. package/dist/core/validationRunner.d.ts +3 -1
  133. package/dist/core/validationRunner.d.ts.map +1 -1
  134. package/dist/core/validationRunner.js.map +1 -1
  135. package/dist/headless/headlessApp.d.ts.map +1 -1
  136. package/dist/headless/headlessApp.js +0 -21
  137. package/dist/headless/headlessApp.js.map +1 -1
  138. package/dist/mcp/sseClient.d.ts.map +1 -1
  139. package/dist/mcp/sseClient.js +18 -9
  140. package/dist/mcp/sseClient.js.map +1 -1
  141. package/dist/plugins/tools/build/buildPlugin.d.ts +6 -0
  142. package/dist/plugins/tools/build/buildPlugin.d.ts.map +1 -1
  143. package/dist/plugins/tools/build/buildPlugin.js +10 -4
  144. package/dist/plugins/tools/build/buildPlugin.js.map +1 -1
  145. package/dist/plugins/tools/nodeDefaults.d.ts.map +1 -1
  146. package/dist/plugins/tools/nodeDefaults.js +2 -0
  147. package/dist/plugins/tools/nodeDefaults.js.map +1 -1
  148. package/dist/plugins/tools/security/securityPlugin.d.ts +3 -0
  149. package/dist/plugins/tools/security/securityPlugin.d.ts.map +1 -0
  150. package/dist/plugins/tools/security/securityPlugin.js +12 -0
  151. package/dist/plugins/tools/security/securityPlugin.js.map +1 -0
  152. package/dist/runtime/agentSession.d.ts +2 -2
  153. package/dist/runtime/agentSession.d.ts.map +1 -1
  154. package/dist/runtime/agentSession.js +2 -2
  155. package/dist/runtime/agentSession.js.map +1 -1
  156. package/dist/security/active-stack-security.d.ts +112 -0
  157. package/dist/security/active-stack-security.d.ts.map +1 -0
  158. package/dist/security/active-stack-security.js +296 -0
  159. package/dist/security/active-stack-security.js.map +1 -0
  160. package/dist/security/advanced-persistence-research.d.ts +92 -0
  161. package/dist/security/advanced-persistence-research.d.ts.map +1 -0
  162. package/dist/security/advanced-persistence-research.js +195 -0
  163. package/dist/security/advanced-persistence-research.js.map +1 -0
  164. package/dist/security/advanced-targeting.d.ts +119 -0
  165. package/dist/security/advanced-targeting.d.ts.map +1 -0
  166. package/dist/security/advanced-targeting.js +233 -0
  167. package/dist/security/advanced-targeting.js.map +1 -0
  168. package/dist/security/assessment/vulnerabilityAssessment.d.ts +104 -0
  169. package/dist/security/assessment/vulnerabilityAssessment.d.ts.map +1 -0
  170. package/dist/security/assessment/vulnerabilityAssessment.js +315 -0
  171. package/dist/security/assessment/vulnerabilityAssessment.js.map +1 -0
  172. package/dist/security/authorization/securityAuthorization.d.ts +88 -0
  173. package/dist/security/authorization/securityAuthorization.d.ts.map +1 -0
  174. package/dist/security/authorization/securityAuthorization.js +172 -0
  175. package/dist/security/authorization/securityAuthorization.js.map +1 -0
  176. package/dist/security/comprehensive-targeting.d.ts +85 -0
  177. package/dist/security/comprehensive-targeting.d.ts.map +1 -0
  178. package/dist/security/comprehensive-targeting.js +438 -0
  179. package/dist/security/comprehensive-targeting.js.map +1 -0
  180. package/dist/security/global-security-integration.d.ts +91 -0
  181. package/dist/security/global-security-integration.d.ts.map +1 -0
  182. package/dist/security/global-security-integration.js +218 -0
  183. package/dist/security/global-security-integration.js.map +1 -0
  184. package/dist/security/index.d.ts +38 -0
  185. package/dist/security/index.d.ts.map +1 -0
  186. package/dist/security/index.js +47 -0
  187. package/dist/security/index.js.map +1 -0
  188. package/dist/security/persistence-analyzer.d.ts +56 -0
  189. package/dist/security/persistence-analyzer.d.ts.map +1 -0
  190. package/dist/security/persistence-analyzer.js +187 -0
  191. package/dist/security/persistence-analyzer.js.map +1 -0
  192. package/dist/security/persistence-cli.d.ts +36 -0
  193. package/dist/security/persistence-cli.d.ts.map +1 -0
  194. package/dist/security/persistence-cli.js +160 -0
  195. package/dist/security/persistence-cli.js.map +1 -0
  196. package/dist/security/persistence-research.d.ts +92 -0
  197. package/dist/security/persistence-research.d.ts.map +1 -0
  198. package/dist/security/persistence-research.js +364 -0
  199. package/dist/security/persistence-research.js.map +1 -0
  200. package/dist/security/research/persistenceResearch.d.ts +97 -0
  201. package/dist/security/research/persistenceResearch.d.ts.map +1 -0
  202. package/dist/security/research/persistenceResearch.js +282 -0
  203. package/dist/security/research/persistenceResearch.js.map +1 -0
  204. package/dist/security/security-integration.d.ts +74 -0
  205. package/dist/security/security-integration.d.ts.map +1 -0
  206. package/dist/security/security-integration.js +137 -0
  207. package/dist/security/security-integration.js.map +1 -0
  208. package/dist/security/security-testing-framework.d.ts +112 -0
  209. package/dist/security/security-testing-framework.d.ts.map +1 -0
  210. package/dist/security/security-testing-framework.js +364 -0
  211. package/dist/security/security-testing-framework.js.map +1 -0
  212. package/dist/security/simulation/attackSimulation.d.ts +93 -0
  213. package/dist/security/simulation/attackSimulation.d.ts.map +1 -0
  214. package/dist/security/simulation/attackSimulation.js +341 -0
  215. package/dist/security/simulation/attackSimulation.js.map +1 -0
  216. package/dist/security/strategic-operations.d.ts +100 -0
  217. package/dist/security/strategic-operations.d.ts.map +1 -0
  218. package/dist/security/strategic-operations.js +276 -0
  219. package/dist/security/strategic-operations.js.map +1 -0
  220. package/dist/security/tool-security-wrapper.d.ts +58 -0
  221. package/dist/security/tool-security-wrapper.d.ts.map +1 -0
  222. package/dist/security/tool-security-wrapper.js +156 -0
  223. package/dist/security/tool-security-wrapper.js.map +1 -0
  224. package/dist/shell/claudeCodeStreamHandler.d.ts +145 -0
  225. package/dist/shell/claudeCodeStreamHandler.d.ts.map +1 -0
  226. package/dist/shell/claudeCodeStreamHandler.js +322 -0
  227. package/dist/shell/claudeCodeStreamHandler.js.map +1 -0
  228. package/dist/shell/inputQueueManager.d.ts +144 -0
  229. package/dist/shell/inputQueueManager.d.ts.map +1 -0
  230. package/dist/shell/inputQueueManager.js +290 -0
  231. package/dist/shell/inputQueueManager.js.map +1 -0
  232. package/dist/shell/interactiveShell.d.ts +7 -11
  233. package/dist/shell/interactiveShell.d.ts.map +1 -1
  234. package/dist/shell/interactiveShell.js +157 -190
  235. package/dist/shell/interactiveShell.js.map +1 -1
  236. package/dist/shell/metricsTracker.d.ts +60 -0
  237. package/dist/shell/metricsTracker.d.ts.map +1 -0
  238. package/dist/shell/metricsTracker.js +119 -0
  239. package/dist/shell/metricsTracker.js.map +1 -0
  240. package/dist/shell/shellApp.d.ts +0 -2
  241. package/dist/shell/shellApp.d.ts.map +1 -1
  242. package/dist/shell/shellApp.js +1 -36
  243. package/dist/shell/shellApp.js.map +1 -1
  244. package/dist/shell/streamingOutputManager.d.ts +115 -0
  245. package/dist/shell/streamingOutputManager.d.ts.map +1 -0
  246. package/dist/shell/streamingOutputManager.js +225 -0
  247. package/dist/shell/streamingOutputManager.js.map +1 -0
  248. package/dist/shell/systemPrompt.d.ts.map +1 -1
  249. package/dist/shell/systemPrompt.js +4 -1
  250. package/dist/shell/systemPrompt.js.map +1 -1
  251. package/dist/shell/terminalInput.d.ts +158 -77
  252. package/dist/shell/terminalInput.d.ts.map +1 -1
  253. package/dist/shell/terminalInput.js +803 -488
  254. package/dist/shell/terminalInput.js.map +1 -1
  255. package/dist/shell/terminalInputAdapter.d.ts +25 -20
  256. package/dist/shell/terminalInputAdapter.d.ts.map +1 -1
  257. package/dist/shell/terminalInputAdapter.js +36 -14
  258. package/dist/shell/terminalInputAdapter.js.map +1 -1
  259. package/dist/subagents/taskRunner.d.ts +1 -7
  260. package/dist/subagents/taskRunner.d.ts.map +1 -1
  261. package/dist/subagents/taskRunner.js +47 -180
  262. package/dist/subagents/taskRunner.js.map +1 -1
  263. package/dist/tools/securityTools.d.ts +22 -0
  264. package/dist/tools/securityTools.d.ts.map +1 -0
  265. package/dist/tools/securityTools.js +448 -0
  266. package/dist/tools/securityTools.js.map +1 -0
  267. package/dist/ui/ShellUIAdapter.d.ts.map +1 -1
  268. package/dist/ui/ShellUIAdapter.js +12 -13
  269. package/dist/ui/ShellUIAdapter.js.map +1 -1
  270. package/dist/ui/display.d.ts +0 -19
  271. package/dist/ui/display.d.ts.map +1 -1
  272. package/dist/ui/display.js +33 -131
  273. package/dist/ui/display.js.map +1 -1
  274. package/dist/ui/persistentPrompt.d.ts +50 -0
  275. package/dist/ui/persistentPrompt.d.ts.map +1 -0
  276. package/dist/ui/persistentPrompt.js +92 -0
  277. package/dist/ui/persistentPrompt.js.map +1 -0
  278. package/dist/ui/terminalUISchema.d.ts +195 -0
  279. package/dist/ui/terminalUISchema.d.ts.map +1 -0
  280. package/dist/ui/terminalUISchema.js +113 -0
  281. package/dist/ui/terminalUISchema.js.map +1 -0
  282. package/dist/ui/theme.d.ts.map +1 -1
  283. package/dist/ui/theme.js +8 -6
  284. package/dist/ui/theme.js.map +1 -1
  285. package/dist/ui/toolDisplay.d.ts +158 -0
  286. package/dist/ui/toolDisplay.d.ts.map +1 -1
  287. package/dist/ui/toolDisplay.js +348 -0
  288. package/dist/ui/toolDisplay.js.map +1 -1
  289. package/dist/ui/unified/layout.d.ts +0 -1
  290. package/dist/ui/unified/layout.d.ts.map +1 -1
  291. package/dist/ui/unified/layout.js +25 -15
  292. package/dist/ui/unified/layout.js.map +1 -1
  293. package/package.json +1 -1
  294. package/scripts/deploy-security-capabilities.js +178 -0
  295. package/dist/core/hooks.d.ts +0 -113
  296. package/dist/core/hooks.d.ts.map +0 -1
  297. package/dist/core/hooks.js +0 -267
  298. package/dist/core/hooks.js.map +0 -1
  299. package/dist/core/metricsTracker.d.ts +0 -122
  300. package/dist/core/metricsTracker.d.ts.map +0 -1
  301. package/dist/core/metricsTracker.js.map +0 -1
  302. package/dist/core/securityAssessment.d.ts +0 -91
  303. package/dist/core/securityAssessment.d.ts.map +0 -1
  304. package/dist/core/securityAssessment.js +0 -580
  305. package/dist/core/securityAssessment.js.map +0 -1
  306. package/dist/core/verification.d.ts +0 -137
  307. package/dist/core/verification.d.ts.map +0 -1
  308. package/dist/core/verification.js +0 -323
  309. package/dist/core/verification.js.map +0 -1
  310. package/dist/subagents/agentConfig.d.ts +0 -27
  311. package/dist/subagents/agentConfig.d.ts.map +0 -1
  312. package/dist/subagents/agentConfig.js +0 -89
  313. package/dist/subagents/agentConfig.js.map +0 -1
  314. package/dist/subagents/agentRegistry.d.ts +0 -33
  315. package/dist/subagents/agentRegistry.d.ts.map +0 -1
  316. package/dist/subagents/agentRegistry.js +0 -162
  317. package/dist/subagents/agentRegistry.js.map +0 -1
  318. package/dist/utils/frontmatter.d.ts +0 -10
  319. package/dist/utils/frontmatter.d.ts.map +0 -1
  320. package/dist/utils/frontmatter.js +0 -78
  321. 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,38 +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 where content cursor is in scroll region (for streaming)
90
- contentCursorRow = 1;
91
- contentCursorCol = 1;
92
- thinkingModeLabel = null;
93
94
  editMode = 'display-edits';
94
95
  verificationEnabled = true;
95
96
  autoContinueEnabled = false;
96
97
  verificationHotkey = 'alt+v';
97
98
  autoContinueHotkey = 'alt+c';
98
- thinkingHotkey = '/thinking';
99
- modelLabel = null;
100
- providerLabel = null;
101
99
  // Output interceptor cleanup
102
100
  outputInterceptorCleanup;
103
- // Streaming render throttle
104
- lastStreamingRender = 0;
105
- 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)
106
107
  streamingRenderTimer = null;
107
108
  constructor(writeStream = process.stdout, config = {}) {
108
109
  super();
109
110
  this.out = writeStream;
111
+ // Use schema defaults for configuration consistency
110
112
  this.config = {
111
- maxLines: config.maxLines ?? 1000,
112
- maxLength: config.maxLength ?? 10000,
113
+ maxLines: config.maxLines ?? DEFAULT_UI_CONFIG.inputArea.maxLines,
114
+ maxLength: config.maxLength ?? DEFAULT_UI_CONFIG.inputArea.maxLength,
113
115
  maxQueueSize: config.maxQueueSize ?? 100,
114
- promptChar: config.promptChar ?? '> ',
115
- continuationChar: config.continuationChar ?? '│ ',
116
+ promptChar: config.promptChar ?? DEFAULT_UI_CONFIG.inputArea.promptChar,
117
+ continuationChar: config.continuationChar ?? DEFAULT_UI_CONFIG.inputArea.continuationChar,
116
118
  };
117
119
  }
118
120
  // ===========================================================================
@@ -191,6 +193,11 @@ export class TerminalInput extends EventEmitter {
191
193
  if (handled)
192
194
  return;
193
195
  }
196
+ // Handle '?' for help hint (if buffer is empty)
197
+ if (str === '?' && this.buffer.length === 0) {
198
+ this.emit('showHelp');
199
+ return;
200
+ }
194
201
  // Insert printable characters
195
202
  if (str && !key?.ctrl && !key?.meta) {
196
203
  this.insertText(str);
@@ -199,38 +206,462 @@ export class TerminalInput extends EventEmitter {
199
206
  /**
200
207
  * Set the input mode
201
208
  *
202
- * Streaming keeps the scroll region active so the prompt/status stay pinned
203
- * 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).
204
212
  */
205
213
  setMode(mode) {
206
214
  const prevMode = this.mode;
207
215
  this.mode = mode;
208
216
  if (mode === 'streaming' && prevMode !== 'streaming') {
209
- // Keep scroll region active so status/prompt stay pinned while streaming
210
- 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
211
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);
212
233
  this.renderDirty = true;
213
- this.render();
214
234
  }
215
235
  else if (mode !== 'streaming' && prevMode === 'streaming') {
216
- // Streaming ended - render the input area
217
- this.resetStreamingRenderThrottle();
218
- this.enableScrollRegion();
219
- this.forceRender();
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
+ this.pinnedTopRows = 0;
244
+ // Ensure no scroll region is active
245
+ this.disableScrollRegion();
246
+ // Reset flow mode tracking
247
+ this.flowModeRenderedLines = 0;
248
+ // Render input area using unified method (same as streaming, but normal mode)
249
+ writeLock.withLock(() => {
250
+ this.renderPinnedInputArea();
251
+ }, 'terminalInput.streamingEnd');
252
+ }
253
+ }
254
+ /**
255
+ * Update streaming status label (called by timer)
256
+ */
257
+ updateStreamingStatus() {
258
+ if (this.mode !== 'streaming' || !this.streamingStartTime)
259
+ return;
260
+ // Calculate elapsed time
261
+ const elapsed = Date.now() - this.streamingStartTime;
262
+ const seconds = Math.floor(elapsed / 1000);
263
+ const minutes = Math.floor(seconds / 60);
264
+ const secs = seconds % 60;
265
+ // Format elapsed time
266
+ let elapsedStr;
267
+ if (minutes > 0) {
268
+ elapsedStr = `${minutes}m ${secs}s`;
269
+ }
270
+ else {
271
+ elapsedStr = `${secs}s`;
272
+ }
273
+ // Update streaming label
274
+ this.streamingLabel = `Streaming ${elapsedStr}`;
275
+ }
276
+ /**
277
+ * Render input area - unified for streaming and normal modes.
278
+ *
279
+ * In streaming mode: renders at absolute bottom, uses cursor save/restore
280
+ * In normal mode: renders right after the banner (pinnedTopRows + 1)
281
+ */
282
+ renderPinnedInputArea() {
283
+ const { rows, cols } = this.getSize();
284
+ const maxWidth = Math.max(8, cols - 4);
285
+ const divider = renderDivider(cols - 2);
286
+ const isStreaming = this.mode === 'streaming';
287
+ // Wrap buffer into display lines (multi-line support)
288
+ const { lines, cursorLine, cursorCol } = this.wrapBuffer(maxWidth);
289
+ const availableForContent = Math.max(1, rows - 5); // Reserve 5 lines for UI
290
+ const maxVisible = Math.max(1, Math.min(this.config.maxLines, availableForContent));
291
+ const displayLines = Math.min(lines.length, maxVisible);
292
+ // Calculate display window (keep cursor visible)
293
+ let startLine = 0;
294
+ if (lines.length > displayLines) {
295
+ startLine = Math.max(0, cursorLine - displayLines + 1);
296
+ startLine = Math.min(startLine, lines.length - displayLines);
297
+ }
298
+ const visibleLines = lines.slice(startLine, startLine + displayLines);
299
+ const adjustedCursorLine = cursorLine - startLine;
300
+ // Calculate total height: status + (model info if set) + topDiv + input lines + bottomDiv + controls
301
+ const hasModelInfo = !!this.modelInfo;
302
+ const totalHeight = 4 + visibleLines.length + (hasModelInfo ? 1 : 0);
303
+ // Save cursor position during streaming (so content flow resumes correctly)
304
+ if (isStreaming) {
305
+ this.write(ESC.SAVE);
306
+ }
307
+ this.write(ESC.HIDE);
308
+ this.write(ESC.RESET);
309
+ // Calculate start row based on mode:
310
+ // - Streaming: absolute bottom (rows - totalHeight + 1)
311
+ // - Normal: right after content (contentEndRow + 1)
312
+ let currentRow;
313
+ if (isStreaming) {
314
+ currentRow = Math.max(1, rows - totalHeight + 1);
315
+ }
316
+ else {
317
+ // In normal mode, render right after content
318
+ // Use contentEndRow if set, otherwise use pinnedTopRows
319
+ const contentRow = this.contentEndRow > 0 ? this.contentEndRow : this.pinnedTopRows;
320
+ currentRow = Math.max(1, contentRow + 1);
321
+ }
322
+ let finalRow = currentRow;
323
+ let finalCol = 3;
324
+ // Clear from current position to end of screen to remove any "ghost" content
325
+ this.write(ESC.TO(currentRow, 1));
326
+ this.write(ESC.CLEAR_TO_END);
327
+ // Status bar
328
+ this.write(ESC.TO(currentRow, 1));
329
+ this.write(isStreaming ? this.buildStreamingStatusBar(cols) : this.buildStatusBar(cols));
330
+ currentRow++;
331
+ // Model info line (if set) - displayed below status, above input
332
+ if (hasModelInfo) {
333
+ const { dim: DIM, reset: R } = UI_COLORS;
334
+ this.write(ESC.TO(currentRow, 1));
335
+ // Build model info with context usage
336
+ let modelLine = `${DIM}${this.modelInfo}${R}`;
337
+ if (this.contextUsage !== null) {
338
+ const rem = Math.max(0, 100 - this.contextUsage);
339
+ if (rem < 10)
340
+ modelLine += ` ${UI_COLORS.red}⚠ ctx: ${rem}%${R}`;
341
+ else if (rem < 25)
342
+ modelLine += ` ${UI_COLORS.yellow}! ctx: ${rem}%${R}`;
343
+ else
344
+ modelLine += ` ${DIM}· ctx: ${rem}%${R}`;
345
+ }
346
+ this.write(modelLine);
347
+ currentRow++;
348
+ }
349
+ // Top divider
350
+ this.write(ESC.TO(currentRow, 1));
351
+ this.write(divider);
352
+ currentRow++;
353
+ // Input lines with background styling
354
+ for (let i = 0; i < visibleLines.length; i++) {
355
+ this.write(ESC.TO(currentRow, 1));
356
+ const line = visibleLines[i] ?? '';
357
+ const absoluteLineIdx = startLine + i;
358
+ const isFirstLine = absoluteLineIdx === 0;
359
+ const isCursorLine = i === adjustedCursorLine;
360
+ // Background
361
+ this.write(ESC.BG_DARK);
362
+ // Prompt prefix
363
+ this.write(ESC.DIM);
364
+ this.write(isFirstLine ? this.config.promptChar : this.config.continuationChar);
365
+ this.write(ESC.RESET);
366
+ this.write(ESC.BG_DARK);
367
+ if (isCursorLine) {
368
+ const col = Math.min(cursorCol, line.length);
369
+ const before = line.slice(0, col);
370
+ const at = col < line.length ? line[col] : ' ';
371
+ const after = col < line.length ? line.slice(col + 1) : '';
372
+ this.write(before);
373
+ this.write(ESC.REVERSE + ESC.BOLD);
374
+ this.write(at);
375
+ this.write(ESC.RESET + ESC.BG_DARK);
376
+ this.write(after);
377
+ finalRow = currentRow;
378
+ finalCol = this.config.promptChar.length + col + 1;
379
+ }
380
+ else {
381
+ this.write(line);
382
+ }
383
+ // Pad to edge
384
+ const lineLen = this.config.promptChar.length + line.length + (isCursorLine && cursorCol >= line.length ? 1 : 0);
385
+ const padding = Math.max(0, cols - lineLen - 1);
386
+ if (padding > 0)
387
+ this.write(' '.repeat(padding));
388
+ this.write(ESC.RESET);
389
+ currentRow++;
390
+ }
391
+ // Bottom divider
392
+ this.write(ESC.TO(currentRow, 1));
393
+ this.write(divider);
394
+ currentRow++;
395
+ // Mode controls line
396
+ this.write(ESC.TO(currentRow, 1));
397
+ this.write(this.buildModeControls(cols));
398
+ // Restore cursor position during streaming, or show cursor in normal mode
399
+ if (isStreaming) {
400
+ this.write(ESC.RESTORE);
401
+ }
402
+ else {
403
+ // Position cursor in input area
404
+ this.write(ESC.TO(finalRow, Math.min(finalCol, cols)));
405
+ this.write(ESC.SHOW);
406
+ }
407
+ // Update reserved lines for scroll region calculations
408
+ this.updateReservedLines(totalHeight);
409
+ }
410
+ /**
411
+ * Render input area during streaming (alias for unified method)
412
+ */
413
+ renderStreamingInputArea() {
414
+ this.renderPinnedInputArea();
415
+ }
416
+ /**
417
+ * Render bottom input area in the reserved scroll region space.
418
+ * Uses cursor save/restore to update bottom without affecting content flow.
419
+ */
420
+ renderBottomInputArea() {
421
+ const { rows, cols } = this.getSize();
422
+ const maxWidth = Math.max(8, cols - 4);
423
+ const divider = renderDivider(cols - 2);
424
+ const { dim: DIM, reset: R, green: GREEN } = UI_COLORS;
425
+ // Wrap buffer into display lines
426
+ const { lines, cursorLine, cursorCol } = this.wrapBuffer(maxWidth);
427
+ const displayLines = Math.min(lines.length, 1); // Just show first line during streaming
428
+ const visibleLines = lines.slice(0, displayLines);
429
+ // Calculate total height for bottom area
430
+ const hasModelInfo = !!this.modelInfo;
431
+ const totalHeight = 4 + displayLines + (hasModelInfo ? 1 : 0);
432
+ const startRow = Math.max(1, rows - totalHeight + 1);
433
+ // Save cursor, hide it
434
+ this.write(ESC.SAVE);
435
+ this.write(ESC.HIDE);
436
+ let currentRow = startRow;
437
+ // Clear the bottom reserved area
438
+ for (let r = startRow; r <= rows; r++) {
439
+ this.write(ESC.TO(r, 1));
440
+ this.write(ESC.CLEAR_LINE);
441
+ }
442
+ // Status bar (streaming timer)
443
+ this.write(ESC.TO(currentRow, 1));
444
+ this.write(this.buildStreamingStatusBar(cols));
445
+ currentRow++;
446
+ // Model info line (if set)
447
+ if (hasModelInfo) {
448
+ this.write(ESC.TO(currentRow, 1));
449
+ let modelLine = `${DIM}${this.modelInfo}${R}`;
450
+ if (this.contextUsage !== null) {
451
+ const rem = Math.max(0, 100 - this.contextUsage);
452
+ if (rem < 10)
453
+ modelLine += ` ${UI_COLORS.red}⚠ ctx: ${rem}%${R}`;
454
+ else if (rem < 25)
455
+ modelLine += ` ${UI_COLORS.yellow}! ctx: ${rem}%${R}`;
456
+ else
457
+ modelLine += ` ${DIM}· ctx: ${rem}%${R}`;
458
+ }
459
+ this.write(modelLine);
460
+ currentRow++;
461
+ }
462
+ // Top divider
463
+ this.write(ESC.TO(currentRow, 1));
464
+ this.write(divider);
465
+ currentRow++;
466
+ // Input lines with background styling
467
+ for (let i = 0; i < visibleLines.length; i++) {
468
+ this.write(ESC.TO(currentRow, 1));
469
+ const line = visibleLines[i] ?? '';
470
+ const isFirstLine = i === 0;
471
+ this.write(ESC.BG_DARK);
472
+ this.write(ESC.DIM);
473
+ this.write(isFirstLine ? this.config.promptChar : this.config.continuationChar);
474
+ this.write(ESC.RESET);
475
+ this.write(ESC.BG_DARK);
476
+ this.write(line);
477
+ // Pad to edge
478
+ const lineLen = this.config.promptChar.length + line.length;
479
+ const padding = Math.max(0, cols - lineLen - 1);
480
+ if (padding > 0)
481
+ this.write(' '.repeat(padding));
482
+ this.write(ESC.RESET);
483
+ currentRow++;
484
+ }
485
+ // Bottom divider
486
+ this.write(ESC.TO(currentRow, 1));
487
+ this.write(divider);
488
+ currentRow++;
489
+ // Mode controls
490
+ this.write(ESC.TO(currentRow, 1));
491
+ this.write(this.buildModeControls(cols));
492
+ // Restore cursor position (back to content area)
493
+ this.write(ESC.RESTORE);
494
+ this.write(ESC.SHOW);
495
+ }
496
+ /**
497
+ * Enable or disable flow mode.
498
+ * In flow mode, the input renders immediately after content (wherever cursor is).
499
+ * When disabled, input renders at the absolute bottom of terminal.
500
+ */
501
+ setFlowMode(enabled) {
502
+ if (this.flowMode === enabled)
503
+ return;
504
+ this.flowMode = enabled;
505
+ this.renderDirty = true;
506
+ this.scheduleRender();
507
+ }
508
+ /**
509
+ * Check if flow mode is enabled.
510
+ */
511
+ isFlowMode() {
512
+ return this.flowMode;
513
+ }
514
+ /**
515
+ * Set the row where content ends (for idle mode positioning).
516
+ * Input area will render starting from this row + 1.
517
+ */
518
+ setContentEndRow(row) {
519
+ this.contentEndRow = Math.max(0, row);
520
+ this.renderDirty = true;
521
+ this.scheduleRender();
522
+ }
523
+ /**
524
+ * Set available slash commands for auto-complete suggestions.
525
+ */
526
+ setCommands(commands) {
527
+ this.commandSuggestions = commands;
528
+ this.updateSuggestions();
529
+ }
530
+ /**
531
+ * Update filtered suggestions based on current input.
532
+ */
533
+ updateSuggestions() {
534
+ const input = this.buffer.trim();
535
+ // Only show suggestions when input starts with "/"
536
+ if (!input.startsWith('/')) {
537
+ this.showSuggestions = false;
538
+ this.filteredSuggestions = [];
539
+ this.selectedSuggestionIndex = 0;
540
+ return;
541
+ }
542
+ const query = input.toLowerCase();
543
+ this.filteredSuggestions = this.commandSuggestions.filter(cmd => cmd.command.toLowerCase().startsWith(query) ||
544
+ cmd.command.toLowerCase().includes(query.slice(1)));
545
+ // Show suggestions if we have matches
546
+ this.showSuggestions = this.filteredSuggestions.length > 0;
547
+ // Keep selection in bounds
548
+ if (this.selectedSuggestionIndex >= this.filteredSuggestions.length) {
549
+ this.selectedSuggestionIndex = Math.max(0, this.filteredSuggestions.length - 1);
220
550
  }
221
551
  }
552
+ /**
553
+ * Select next suggestion (arrow down / tab).
554
+ */
555
+ selectNextSuggestion() {
556
+ if (!this.showSuggestions || this.filteredSuggestions.length === 0)
557
+ return;
558
+ this.selectedSuggestionIndex = (this.selectedSuggestionIndex + 1) % this.filteredSuggestions.length;
559
+ this.renderDirty = true;
560
+ this.scheduleRender();
561
+ }
562
+ /**
563
+ * Select previous suggestion (arrow up / shift+tab).
564
+ */
565
+ selectPrevSuggestion() {
566
+ if (!this.showSuggestions || this.filteredSuggestions.length === 0)
567
+ return;
568
+ this.selectedSuggestionIndex = this.selectedSuggestionIndex === 0
569
+ ? this.filteredSuggestions.length - 1
570
+ : this.selectedSuggestionIndex - 1;
571
+ this.renderDirty = true;
572
+ this.scheduleRender();
573
+ }
574
+ /**
575
+ * Accept current suggestion and insert into buffer.
576
+ */
577
+ acceptSuggestion() {
578
+ if (!this.showSuggestions || this.filteredSuggestions.length === 0)
579
+ return false;
580
+ const selected = this.filteredSuggestions[this.selectedSuggestionIndex];
581
+ if (!selected)
582
+ return false;
583
+ // Replace buffer with selected command
584
+ this.buffer = selected.command + ' ';
585
+ this.cursor = this.buffer.length;
586
+ this.showSuggestions = false;
587
+ this.renderDirty = true;
588
+ this.scheduleRender();
589
+ return true;
590
+ }
591
+ /**
592
+ * Check if suggestions are visible.
593
+ */
594
+ areSuggestionsVisible() {
595
+ return this.showSuggestions && this.filteredSuggestions.length > 0;
596
+ }
597
+ /**
598
+ * Update token count for metrics display
599
+ */
600
+ setTokensUsed(tokens) {
601
+ this.tokensUsed = tokens;
602
+ }
603
+ /**
604
+ * Toggle thinking/reasoning mode
605
+ */
606
+ toggleThinking() {
607
+ this.thinkingEnabled = !this.thinkingEnabled;
608
+ this.emit('thinkingToggle', this.thinkingEnabled);
609
+ this.scheduleRender();
610
+ }
611
+ /**
612
+ * Get thinking enabled state
613
+ */
614
+ isThinkingEnabled() {
615
+ return this.thinkingEnabled;
616
+ }
222
617
  /**
223
618
  * Keep the top N rows pinned outside the scroll region (used for the launch banner).
224
619
  */
225
620
  setPinnedHeaderLines(count) {
226
- // No pinned header rows anymore; keep everything in the scroll region.
227
- if (this.pinnedTopRows !== 0) {
228
- this.pinnedTopRows = 0;
621
+ // Set pinned header rows (banner area that scroll region excludes)
622
+ if (this.pinnedTopRows !== count) {
623
+ this.pinnedTopRows = count;
229
624
  if (this.scrollRegionActive) {
230
625
  this.applyScrollRegion();
231
626
  }
232
627
  }
233
628
  }
629
+ /**
630
+ * Anchor prompt rendering near a specific row (inline layout). Pass null to
631
+ * restore the default bottom-aligned layout.
632
+ */
633
+ setInlineAnchor(row) {
634
+ if (row === null || row === undefined) {
635
+ this.inlineAnchorRow = null;
636
+ this.inlineLayout = false;
637
+ this.renderDirty = true;
638
+ this.render();
639
+ return;
640
+ }
641
+ const { rows } = this.getSize();
642
+ const clamped = Math.max(1, Math.min(Math.floor(row), rows));
643
+ this.inlineAnchorRow = clamped;
644
+ this.inlineLayout = true;
645
+ this.renderDirty = true;
646
+ this.render();
647
+ }
648
+ /**
649
+ * Provide a dynamic anchor callback. When set, the prompt will follow the
650
+ * output by re-evaluating the anchor before each render.
651
+ */
652
+ setInlineAnchorProvider(provider) {
653
+ this.anchorProvider = provider;
654
+ if (!provider) {
655
+ this.inlineLayout = false;
656
+ this.inlineAnchorRow = null;
657
+ this.renderDirty = true;
658
+ this.render();
659
+ return;
660
+ }
661
+ this.inlineLayout = true;
662
+ this.renderDirty = true;
663
+ this.render();
664
+ }
234
665
  /**
235
666
  * Get current mode
236
667
  */
@@ -340,37 +771,6 @@ export class TerminalInput extends EventEmitter {
340
771
  this.streamingLabel = next;
341
772
  this.scheduleRender();
342
773
  }
343
- /**
344
- * Surface meta status just above the divider (e.g., elapsed time or token usage).
345
- */
346
- setMetaStatus(meta) {
347
- const nextElapsed = typeof meta.elapsedSeconds === 'number' && Number.isFinite(meta.elapsedSeconds) && meta.elapsedSeconds >= 0
348
- ? Math.floor(meta.elapsedSeconds)
349
- : null;
350
- const nextTokens = typeof meta.tokensUsed === 'number' && Number.isFinite(meta.tokensUsed) && meta.tokensUsed >= 0
351
- ? Math.floor(meta.tokensUsed)
352
- : null;
353
- const nextLimit = typeof meta.tokenLimit === 'number' && Number.isFinite(meta.tokenLimit) && meta.tokenLimit > 0
354
- ? Math.floor(meta.tokenLimit)
355
- : null;
356
- const nextThinking = typeof meta.thinkingMs === 'number' && Number.isFinite(meta.thinkingMs) && meta.thinkingMs >= 0
357
- ? Math.floor(meta.thinkingMs)
358
- : null;
359
- const nextThinkingHasContent = !!meta.thinkingHasContent;
360
- if (this.metaElapsedSeconds === nextElapsed &&
361
- this.metaTokensUsed === nextTokens &&
362
- this.metaTokenLimit === nextLimit &&
363
- this.metaThinkingMs === nextThinking &&
364
- this.metaThinkingHasContent === nextThinkingHasContent) {
365
- return;
366
- }
367
- this.metaElapsedSeconds = nextElapsed;
368
- this.metaTokensUsed = nextTokens;
369
- this.metaTokenLimit = nextLimit;
370
- this.metaThinkingMs = nextThinking;
371
- this.metaThinkingHasContent = nextThinkingHasContent;
372
- this.scheduleRender();
373
- }
374
774
  /**
375
775
  * Keep mode toggles (verification/auto-continue) visible in the control bar.
376
776
  * Hotkey labels remain stable so the bar looks the same before/during streaming.
@@ -380,22 +780,26 @@ export class TerminalInput extends EventEmitter {
380
780
  const nextAutoContinue = !!options.autoContinueEnabled;
381
781
  const nextVerifyHotkey = options.verificationHotkey ?? this.verificationHotkey;
382
782
  const nextAutoHotkey = options.autoContinueHotkey ?? this.autoContinueHotkey;
383
- const nextThinkingHotkey = options.thinkingHotkey ?? this.thinkingHotkey;
384
- const nextThinkingLabel = options.thinkingModeLabel === undefined ? this.thinkingModeLabel : (options.thinkingModeLabel || null);
385
783
  if (this.verificationEnabled === nextVerification &&
386
784
  this.autoContinueEnabled === nextAutoContinue &&
387
785
  this.verificationHotkey === nextVerifyHotkey &&
388
- this.autoContinueHotkey === nextAutoHotkey &&
389
- this.thinkingHotkey === nextThinkingHotkey &&
390
- this.thinkingModeLabel === nextThinkingLabel) {
786
+ this.autoContinueHotkey === nextAutoHotkey) {
391
787
  return;
392
788
  }
393
789
  this.verificationEnabled = nextVerification;
394
790
  this.autoContinueEnabled = nextAutoContinue;
395
791
  this.verificationHotkey = nextVerifyHotkey;
396
792
  this.autoContinueHotkey = nextAutoHotkey;
397
- this.thinkingHotkey = nextThinkingHotkey;
398
- this.thinkingModeLabel = nextThinkingLabel;
793
+ this.scheduleRender();
794
+ }
795
+ /**
796
+ * Set the model info string (e.g., "OpenAI · gpt-4")
797
+ * This is displayed persistently above the input area.
798
+ */
799
+ setModelInfo(info) {
800
+ if (this.modelInfo === info)
801
+ return;
802
+ this.modelInfo = info;
399
803
  this.scheduleRender();
400
804
  }
401
805
  /**
@@ -407,400 +811,302 @@ export class TerminalInput extends EventEmitter {
407
811
  this.streamingLabel = null;
408
812
  this.scheduleRender();
409
813
  }
410
- /**
411
- * Surface model/provider context in the controls bar.
412
- */
413
- setModelContext(options) {
414
- const nextModel = options.model?.trim() || null;
415
- const nextProvider = options.provider?.trim() || null;
416
- if (this.modelLabel === nextModel && this.providerLabel === nextProvider) {
417
- return;
418
- }
419
- this.modelLabel = nextModel;
420
- this.providerLabel = nextProvider;
421
- this.scheduleRender();
422
- }
423
814
  /**
424
815
  * Render the input area - Claude Code style with mode controls
425
816
  *
426
- * During streaming we keep the scroll region active and repaint only the
427
- * pinned status/input block (throttled) so streamed content can scroll
428
- * naturally above while elapsed time and status stay fresh.
817
+ * During streaming: Uses renderBottomInputArea() with scroll regions
818
+ * After streaming: Renders the full input area inline
429
819
  */
430
820
  render() {
431
821
  if (!this.canRender())
432
822
  return;
433
823
  if (this.isRendering)
434
824
  return;
435
- const streamingActive = this.mode === 'streaming' || isStreamingMode();
436
- // During streaming we still render the pinned input/status region, but throttle
437
- // to avoid fighting with the streamed content flow.
438
- if (streamingActive && this.lastStreamingRender > 0) {
439
- const elapsed = Date.now() - this.lastStreamingRender;
440
- const waitMs = Math.max(0, this.streamingRenderInterval - elapsed);
441
- if (waitMs > 0) {
442
- this.renderDirty = true;
443
- this.scheduleStreamingRender(waitMs);
444
- return;
445
- }
825
+ // During streaming, use the bottom input area renderer (with scroll regions)
826
+ if (this.mode === 'streaming') {
827
+ this.renderBottomInputArea();
828
+ return;
446
829
  }
447
830
  const shouldSkip = !this.renderDirty &&
448
831
  this.buffer === this.lastRenderContent &&
449
832
  this.cursor === this.lastRenderCursor;
450
833
  this.renderDirty = false;
451
- // Skip if nothing changed and no explicit refresh requested
834
+ // Skip if nothing changed (unless explicitly forced)
452
835
  if (shouldSkip) {
453
836
  return;
454
837
  }
455
- // If write lock is held, defer render to avoid race conditions
838
+ // If write lock is held, defer render
456
839
  if (writeLock.isLocked()) {
457
840
  writeLock.safeWrite(() => this.render());
458
841
  return;
459
842
  }
460
- const performRender = () => {
461
- if (!this.scrollRegionActive) {
462
- this.enableScrollRegion();
463
- }
464
- const { rows, cols } = this.getSize();
465
- const maxWidth = Math.max(8, cols - 4);
466
- // Wrap buffer into display lines
467
- const { lines, cursorLine, cursorCol } = this.wrapBuffer(maxWidth);
468
- const availableForContent = Math.max(1, rows - 3); // room for separator + input + controls
469
- const maxVisible = Math.max(1, Math.min(this.config.maxLines, availableForContent));
470
- const displayLines = Math.min(lines.length, maxVisible);
471
- const metaLines = this.buildMetaLines(cols - 2);
472
- // Reserved lines: optional meta lines + separator(1) + input lines + controls(1)
473
- this.updateReservedLines(displayLines + 2 + metaLines.length);
474
- // Calculate display window (keep cursor visible)
475
- let startLine = 0;
476
- if (lines.length > displayLines) {
477
- startLine = Math.max(0, cursorLine - displayLines + 1);
478
- startLine = Math.min(startLine, lines.length - displayLines);
479
- }
480
- const visibleLines = lines.slice(startLine, startLine + displayLines);
481
- const adjustedCursorLine = cursorLine - startLine;
482
- // Hide cursor during render to prevent flicker
483
- this.write(ESC.HIDE);
484
- this.write(ESC.RESET);
485
- const startRow = Math.max(1, rows - this.reservedLines + 1);
486
- let currentRow = startRow;
487
- // Clear the reserved block to avoid stale meta/status lines
488
- this.clearReservedArea(startRow, this.reservedLines, cols);
489
- // Meta/status header (elapsed, tokens/context)
490
- for (const metaLine of metaLines) {
491
- this.write(ESC.TO(currentRow, 1));
492
- this.write(ESC.CLEAR_LINE);
493
- this.write(metaLine);
494
- currentRow += 1;
495
- }
496
- // Separator line
843
+ this.isRendering = true;
844
+ writeLock.lock('terminalInput.render');
845
+ try {
846
+ // Render input area at bottom (outside scroll region)
847
+ this.renderBottomPinned();
848
+ }
849
+ finally {
850
+ writeLock.unlock();
851
+ this.isRendering = false;
852
+ }
853
+ }
854
+ /**
855
+ * Render in flow mode - delegates to bottom-pinned for stability.
856
+ *
857
+ * Flow mode attempted inline rendering but caused duplicate renders
858
+ * due to unreliable cursor position tracking. Bottom-pinned is reliable.
859
+ */
860
+ renderFlowMode() {
861
+ // Use stable bottom-pinned approach
862
+ this.renderBottomPinned();
863
+ }
864
+ /**
865
+ * Render in bottom-pinned mode - Claude Code style with suggestions
866
+ *
867
+ * Works for both normal and streaming modes:
868
+ * - During streaming: saves/restores cursor position
869
+ * - Status bar shows streaming info or "Type a message"
870
+ *
871
+ * Layout when suggestions visible:
872
+ * - Top divider
873
+ * - Input line(s)
874
+ * - Bottom divider
875
+ * - Suggestions (command list)
876
+ *
877
+ * Layout when suggestions hidden:
878
+ * - Status bar (Ready/Streaming)
879
+ * - Top divider
880
+ * - Input line(s)
881
+ * - Bottom divider
882
+ * - Mode controls
883
+ */
884
+ renderBottomPinned() {
885
+ const { rows, cols } = this.getSize();
886
+ const maxWidth = Math.max(8, cols - 4);
887
+ const isStreaming = this.mode === 'streaming';
888
+ // Use unified pinned input area (works for both streaming and normal)
889
+ // Only use complex rendering when suggestions are visible
890
+ const hasSuggestions = !isStreaming && this.showSuggestions && this.filteredSuggestions.length > 0;
891
+ if (!hasSuggestions) {
892
+ this.renderPinnedInputArea();
893
+ return;
894
+ }
895
+ // Wrap buffer into display lines
896
+ const { lines, cursorLine, cursorCol } = this.wrapBuffer(maxWidth);
897
+ const availableForContent = Math.max(1, rows - 3);
898
+ const maxVisible = Math.max(1, Math.min(this.config.maxLines, availableForContent));
899
+ const displayLines = Math.min(lines.length, maxVisible);
900
+ // Calculate display window (keep cursor visible)
901
+ let startLine = 0;
902
+ if (lines.length > displayLines) {
903
+ startLine = Math.max(0, cursorLine - displayLines + 1);
904
+ startLine = Math.min(startLine, lines.length - displayLines);
905
+ }
906
+ const visibleLines = lines.slice(startLine, startLine + displayLines);
907
+ const adjustedCursorLine = cursorLine - startLine;
908
+ // Calculate suggestion display (not during streaming)
909
+ const suggestionsToShow = (!isStreaming && this.showSuggestions)
910
+ ? this.filteredSuggestions.slice(0, this.maxVisibleSuggestions)
911
+ : [];
912
+ const suggestionLines = suggestionsToShow.length;
913
+ this.write(ESC.HIDE);
914
+ this.write(ESC.RESET);
915
+ const divider = renderDivider(cols - 2);
916
+ // Calculate positions from absolute bottom
917
+ let currentRow;
918
+ if (suggestionLines > 0) {
919
+ // With suggestions: input area + dividers + suggestions
920
+ // Layout: [topDiv] [input] [bottomDiv] [suggestions...]
921
+ const totalHeight = 2 + visibleLines.length + suggestionLines; // 2 dividers
922
+ currentRow = Math.max(1, rows - totalHeight + 1);
923
+ this.updateReservedLines(totalHeight);
924
+ // Clear from current position to end of screen to remove any "ghost" content
925
+ this.write(ESC.TO(currentRow, 1));
926
+ this.write(ESC.CLEAR_TO_END);
927
+ // Top divider
497
928
  this.write(ESC.TO(currentRow, 1));
498
- this.write(ESC.CLEAR_LINE);
499
- const divider = renderDivider(cols - 2);
500
929
  this.write(divider);
501
- currentRow += 1;
502
- // Render input lines
930
+ currentRow++;
931
+ // Input lines
503
932
  let finalRow = currentRow;
504
933
  let finalCol = 3;
505
934
  for (let i = 0; i < visibleLines.length; i++) {
506
- const rowNum = currentRow + i;
507
- this.write(ESC.TO(rowNum, 1));
508
- this.write(ESC.CLEAR_LINE);
935
+ this.write(ESC.TO(currentRow, 1));
509
936
  const line = visibleLines[i] ?? '';
510
937
  const absoluteLineIdx = startLine + i;
511
938
  const isFirstLine = absoluteLineIdx === 0;
512
939
  const isCursorLine = i === adjustedCursorLine;
513
- // Background
514
- this.write(ESC.BG_DARK);
515
- // Prompt prefix
516
- this.write(ESC.DIM);
517
940
  this.write(isFirstLine ? this.config.promptChar : this.config.continuationChar);
518
- this.write(ESC.RESET);
519
- this.write(ESC.BG_DARK);
520
941
  if (isCursorLine) {
521
- // Render with block cursor
522
942
  const col = Math.min(cursorCol, line.length);
523
- const before = line.slice(0, col);
524
- const at = col < line.length ? line[col] : ' ';
525
- const after = col < line.length ? line.slice(col + 1) : '';
526
- this.write(before);
527
- this.write(ESC.REVERSE + ESC.BOLD);
528
- this.write(at);
529
- this.write(ESC.RESET + ESC.BG_DARK);
530
- this.write(after);
531
- finalRow = rowNum;
943
+ this.write(line.slice(0, col));
944
+ this.write(ESC.REVERSE);
945
+ this.write(col < line.length ? line[col] : ' ');
946
+ this.write(ESC.RESET);
947
+ this.write(line.slice(col + 1));
948
+ finalRow = currentRow;
532
949
  finalCol = this.config.promptChar.length + col + 1;
533
950
  }
534
951
  else {
535
952
  this.write(line);
536
953
  }
537
- // Pad to edge for clean look
538
- const lineLen = this.config.promptChar.length + line.length + (isCursorLine && cursorCol >= line.length ? 1 : 0);
539
- const padding = Math.max(0, cols - lineLen - 1);
540
- if (padding > 0)
541
- this.write(' '.repeat(padding));
542
- this.write(ESC.RESET);
543
- }
544
- // Mode controls line (Claude Code style)
545
- const controlRow = currentRow + visibleLines.length;
546
- this.write(ESC.TO(controlRow, 1));
547
- this.write(ESC.CLEAR_LINE);
548
- this.write(this.buildModeControls(cols));
549
- // During streaming, position cursor back at content location (interceptor tracks this).
550
- // When not streaming, position cursor in the input box for user editing.
551
- if (streamingActive) {
552
- // Move cursor back to scroll region where content continues
553
- this.write(ESC.TO(this.contentCursorRow, this.contentCursorCol));
554
- this.write(ESC.SHOW);
954
+ currentRow++;
555
955
  }
556
- else {
557
- // Position cursor in the input box
558
- this.write(ESC.TO(finalRow, Math.min(finalCol, cols)));
559
- this.write(ESC.SHOW);
560
- }
561
- // Update state
562
- this.lastRenderContent = this.buffer;
563
- this.lastRenderCursor = this.cursor;
564
- this.lastStreamingRender = streamingActive ? Date.now() : 0;
565
- if (this.streamingRenderTimer) {
566
- clearTimeout(this.streamingRenderTimer);
567
- this.streamingRenderTimer = null;
956
+ // Bottom divider
957
+ this.write(ESC.TO(currentRow, 1));
958
+ this.write(divider);
959
+ currentRow++;
960
+ // Suggestions (Claude Code style)
961
+ for (let i = 0; i < suggestionsToShow.length; i++) {
962
+ this.write(ESC.TO(currentRow, 1));
963
+ const suggestion = suggestionsToShow[i];
964
+ const isSelected = i === this.selectedSuggestionIndex;
965
+ // Indent and highlight selected
966
+ this.write(' ');
967
+ if (isSelected) {
968
+ this.write(ESC.REVERSE);
969
+ this.write(ESC.BOLD);
970
+ }
971
+ this.write(suggestion.command);
972
+ if (isSelected) {
973
+ this.write(ESC.RESET);
974
+ }
975
+ // Description (dimmed)
976
+ const descSpace = cols - suggestion.command.length - 8;
977
+ if (descSpace > 10 && suggestion.description) {
978
+ const desc = suggestion.description.slice(0, descSpace);
979
+ this.write(ESC.RESET);
980
+ this.write(ESC.DIM);
981
+ this.write(' ');
982
+ this.write(desc);
983
+ this.write(ESC.RESET);
984
+ }
985
+ currentRow++;
568
986
  }
569
- };
570
- // Use write lock during render to prevent interleaved output
571
- writeLock.lock('terminalInput.render');
572
- this.isRendering = true;
573
- try {
574
- performRender();
575
- }
576
- finally {
577
- writeLock.unlock();
578
- this.isRendering = false;
987
+ // Position cursor in input area
988
+ this.write(ESC.TO(finalRow, Math.min(finalCol, cols)));
579
989
  }
990
+ this.write(ESC.SHOW);
991
+ // Update state
992
+ this.lastRenderContent = this.buffer;
993
+ this.lastRenderCursor = this.cursor;
580
994
  }
581
995
  /**
582
- * Build one or more compact meta lines above the divider (thinking, status, usage).
583
- * During streaming, shows model line pinned above streaming info.
996
+ * Build status bar for streaming mode (shows elapsed time, queue count).
584
997
  */
585
- buildMetaLines(width) {
586
- const streamingActive = this.mode === 'streaming' || isStreamingMode();
587
- const lines = [];
588
- // Model line should ALWAYS be shown (pinned above streaming content)
589
- if (this.modelLabel) {
590
- const modelText = this.providerLabel
591
- ? `model ${this.modelLabel} @ ${this.providerLabel}`
592
- : `model ${this.modelLabel}`;
593
- lines.push(renderStatusLine([{ text: modelText, tone: 'info' }], width));
594
- }
595
- // During streaming, add a compact status line with essential info
596
- if (streamingActive) {
597
- const parts = [];
598
- // Essential streaming info
599
- if (this.metaThinkingMs !== null) {
600
- parts.push({ text: formatThinking(this.metaThinkingMs, this.metaThinkingHasContent), tone: 'info' });
601
- }
602
- if (this.metaElapsedSeconds !== null) {
603
- parts.push({ text: this.formatElapsedLabel(this.metaElapsedSeconds), tone: 'muted' });
604
- }
605
- parts.push({ text: 'esc to stop', tone: 'warn' });
606
- if (parts.length) {
607
- lines.push(renderStatusLine(parts, width));
608
- }
609
- return lines;
610
- }
611
- // Non-streaming: show full status info (model line already added above)
612
- if (this.metaThinkingMs !== null) {
613
- const thinkingText = formatThinking(this.metaThinkingMs, this.metaThinkingHasContent);
614
- lines.push(renderStatusLine([{ text: thinkingText, tone: 'info' }], width));
615
- }
616
- const statusParts = [];
617
- const statusLabel = this.statusMessage ?? this.streamingLabel;
618
- if (statusLabel) {
619
- statusParts.push({ text: statusLabel, tone: 'info' });
620
- }
621
- if (this.metaElapsedSeconds !== null) {
622
- statusParts.push({ text: this.formatElapsedLabel(this.metaElapsedSeconds), tone: 'muted' });
623
- }
624
- const tokensRemaining = this.computeTokensRemaining();
625
- if (tokensRemaining !== null) {
626
- statusParts.push({ text: `↓ ${tokensRemaining}`, tone: 'muted' });
627
- }
628
- if (statusParts.length) {
629
- lines.push(renderStatusLine(statusParts, width));
630
- }
631
- const usageParts = [];
632
- if (this.metaTokensUsed !== null) {
633
- const formattedUsed = this.formatTokenCount(this.metaTokensUsed);
634
- const formattedLimit = this.metaTokenLimit ? `/${this.formatTokenCount(this.metaTokenLimit)}` : '';
635
- usageParts.push({ text: `tokens ${formattedUsed}${formattedLimit}`, tone: 'muted' });
636
- }
637
- if (this.contextUsage !== null) {
638
- const tone = this.contextUsage >= 75 ? 'warn' : 'muted';
639
- const left = Math.max(0, 100 - this.contextUsage);
640
- usageParts.push({ text: `context used ${this.contextUsage}% (${left}% left)`, tone });
641
- }
998
+ buildStreamingStatusBar(cols) {
999
+ const { green: GREEN, cyan: CYAN, dim: DIM, reset: R } = UI_COLORS;
1000
+ // Streaming status with elapsed time
1001
+ let elapsed = '0s';
1002
+ if (this.streamingStartTime) {
1003
+ const secs = Math.floor((Date.now() - this.streamingStartTime) / 1000);
1004
+ const mins = Math.floor(secs / 60);
1005
+ elapsed = mins > 0 ? `${mins}m ${secs % 60}s` : `${secs}s`;
1006
+ }
1007
+ let status = `${GREEN}● Streaming${R} ${elapsed}`;
1008
+ // Queue indicator
642
1009
  if (this.queue.length > 0) {
643
- usageParts.push({ text: `queued ${this.queue.length}`, tone: 'info' });
644
- }
645
- if (usageParts.length) {
646
- lines.push(renderStatusLine(usageParts, width));
1010
+ status += ` ${DIM}·${R} ${CYAN}${this.queue.length} queued${R}`;
647
1011
  }
648
- return lines;
1012
+ // Hint for typing
1013
+ status += ` ${DIM}· type to queue message${R}`;
1014
+ return status;
649
1015
  }
650
1016
  /**
651
- * Clear the reserved bottom block (meta + divider + input + controls) before repainting.
1017
+ * Build status bar showing streaming/ready status and key info.
1018
+ * This is the TOP line above the input area - minimal Claude Code style.
652
1019
  */
653
- clearReservedArea(startRow, reservedLines, cols) {
654
- const width = Math.max(1, cols);
655
- for (let i = 0; i < reservedLines; i++) {
656
- const row = startRow + i;
657
- this.write(ESC.TO(row, 1));
658
- this.write(' '.repeat(width));
659
- }
660
- }
661
- /**
662
- * Build Claude Code style mode controls line.
663
- * Combines streaming label + override status + main status for simultaneous display.
664
- */
665
- buildModeControls(cols) {
666
- const width = Math.max(8, cols - 2);
667
- const leftParts = [];
668
- const rightParts = [];
669
- if (this.streamingLabel) {
670
- leftParts.push({ text: `◐ ${this.streamingLabel}`, tone: 'info' });
671
- }
672
- if (this.overrideStatusMessage) {
673
- leftParts.push({ text: `⚠ ${this.overrideStatusMessage}`, tone: 'warn' });
674
- }
675
- if (this.statusMessage) {
676
- leftParts.push({ text: this.statusMessage, tone: this.streamingLabel ? 'muted' : 'info' });
677
- }
678
- const editHotkey = this.formatHotkey('shift+tab');
679
- const editLabel = this.editMode === 'display-edits' ? 'edits: accept' : 'edits: ask';
680
- const editTone = this.editMode === 'display-edits' ? 'success' : 'muted';
681
- leftParts.push({ text: `${editHotkey} ${editLabel}`, tone: editTone });
682
- const verifyHotkey = this.formatHotkey(this.verificationHotkey);
683
- const verifyLabel = this.verificationEnabled ? 'verify:on' : 'verify:off';
684
- leftParts.push({ text: `${verifyHotkey} ${verifyLabel}`, tone: this.verificationEnabled ? 'success' : 'muted' });
685
- const continueHotkey = this.formatHotkey(this.autoContinueHotkey);
686
- const continueLabel = this.autoContinueEnabled ? 'auto:on' : 'auto:off';
687
- leftParts.push({ text: `${continueHotkey} ${continueLabel}`, tone: this.autoContinueEnabled ? 'info' : 'muted' });
688
- if (this.queue.length > 0 && this.mode !== 'streaming') {
689
- leftParts.push({ text: `queued ${this.queue.length}`, tone: 'info' });
1020
+ buildStatusBar(cols) {
1021
+ const maxWidth = cols - 2;
1022
+ const parts = [];
1023
+ // Streaming status with elapsed time (left side)
1024
+ if (this.mode === 'streaming') {
1025
+ let statusText = ' Streaming';
1026
+ if (this.streamingStartTime) {
1027
+ const elapsed = Math.floor((Date.now() - this.streamingStartTime) / 1000);
1028
+ const mins = Math.floor(elapsed / 60);
1029
+ const secs = elapsed % 60;
1030
+ statusText += mins > 0 ? ` ${mins}m ${secs}s` : ` ${secs}s`;
1031
+ }
1032
+ parts.push(`\x1b[32m${statusText}\x1b[0m`); // Green
690
1033
  }
691
- if (this.buffer.includes('\n')) {
692
- const lineCount = this.buffer.split('\n').length;
693
- leftParts.push({ text: `${lineCount} lines`, tone: 'muted' });
1034
+ // Queue indicator during streaming
1035
+ if (this.mode === 'streaming' && this.queue.length > 0) {
1036
+ parts.push(`\x1b[36mqueued: ${this.queue.length}\x1b[0m`); // Cyan
694
1037
  }
1038
+ // Paste indicator
695
1039
  if (this.pastePlaceholders.length > 0) {
696
- const latest = this.pastePlaceholders[this.pastePlaceholders.length - 1];
697
- leftParts.push({
698
- text: `paste #${latest.id} +${latest.lineCount} lines (⌫ to drop)`,
699
- tone: 'info',
700
- });
1040
+ const totalLines = this.pastePlaceholders.reduce((sum, p) => sum + p.lineCount, 0);
1041
+ parts.push(`\x1b[36m📋 ${totalLines}L\x1b[0m`);
701
1042
  }
702
- const contextRemaining = this.computeContextRemaining();
703
- if (this.thinkingModeLabel) {
704
- const thinkingHotkey = this.formatHotkey(this.thinkingHotkey);
705
- rightParts.push({ text: `${thinkingHotkey} thinking:${this.thinkingModeLabel}`, tone: 'info' });
706
- }
707
- // Show model in controls only when NOT streaming (during streaming it's in meta lines)
708
- const streamingActive = this.mode === 'streaming' || isStreamingMode();
709
- if (this.modelLabel && !streamingActive) {
710
- const modelText = this.providerLabel ? `${this.modelLabel} @ ${this.providerLabel}` : this.modelLabel;
711
- rightParts.push({ text: modelText, tone: 'muted' });
712
- }
713
- if (contextRemaining !== null) {
714
- const tone = contextRemaining <= 10 ? 'warn' : 'muted';
715
- const label = contextRemaining === 0 && this.contextUsage !== null
716
- ? 'Context auto-compact imminent'
717
- : `Context left until auto-compact: ${contextRemaining}%`;
718
- rightParts.push({ text: label, tone });
719
- }
720
- if (!rightParts.length || width < 60) {
721
- const merged = rightParts.length ? [...leftParts, ...rightParts] : leftParts;
722
- return renderStatusLine(merged, width);
723
- }
724
- const leftWidth = Math.max(12, Math.floor(width * 0.6));
725
- const rightWidth = Math.max(14, width - leftWidth - 1);
726
- const leftText = renderStatusLine(leftParts, leftWidth);
727
- const rightText = renderStatusLine(rightParts, rightWidth);
728
- const spacing = Math.max(1, width - this.visibleLength(leftText) - this.visibleLength(rightText));
729
- return `${leftText}${' '.repeat(spacing)}${rightText}`;
730
- }
731
- formatHotkey(hotkey) {
732
- const normalized = hotkey.trim().toLowerCase();
733
- if (!normalized)
734
- return hotkey;
735
- const parts = normalized.split('+').filter(Boolean);
736
- const map = {
737
- shift: '⇧',
738
- sh: '⇧',
739
- alt: '⌥',
740
- option: '⌥',
741
- opt: '⌥',
742
- ctrl: '⌃',
743
- control: '⌃',
744
- cmd: '⌘',
745
- meta: '⌘',
746
- };
747
- const formatted = parts
748
- .map((part) => {
749
- const symbol = map[part];
750
- if (symbol)
751
- return symbol;
752
- return part.length === 1 ? part.toUpperCase() : part.toUpperCase();
753
- })
754
- .join('');
755
- return formatted || hotkey;
756
- }
757
- computeContextRemaining() {
758
- if (this.contextUsage === null) {
759
- return null;
760
- }
761
- return Math.max(0, this.contextAutoCompactThreshold - this.contextUsage);
762
- }
763
- computeTokensRemaining() {
764
- if (this.metaTokensUsed === null || this.metaTokenLimit === null) {
765
- return null;
766
- }
767
- const remaining = Math.max(0, this.metaTokenLimit - this.metaTokensUsed);
768
- return this.formatTokenCount(remaining);
769
- }
770
- formatElapsedLabel(seconds) {
771
- if (seconds < 60) {
772
- return `${seconds}s`;
773
- }
774
- const mins = Math.floor(seconds / 60);
775
- const secs = seconds % 60;
776
- return secs > 0 ? `${mins}m ${secs}s` : `${mins}m`;
777
- }
778
- formatTokenCount(value) {
779
- if (!Number.isFinite(value)) {
780
- return `${value}`;
1043
+ // Override/warning status
1044
+ if (this.overrideStatusMessage) {
1045
+ parts.push(`\x1b[33m⚠ ${this.overrideStatusMessage}\x1b[0m`); // Yellow
781
1046
  }
782
- if (value >= 1_000_000) {
783
- return `${(value / 1_000_000).toFixed(1)}M`;
1047
+ // If idle with empty buffer, show quick shortcuts
1048
+ if (this.mode !== 'streaming' && this.buffer.length === 0 && parts.length === 0) {
1049
+ return `${ESC.DIM}Type a message or / for commands${ESC.RESET}`;
784
1050
  }
785
- if (value >= 1_000) {
786
- return `${(value / 1_000).toFixed(1)}k`;
1051
+ // Multi-line indicator
1052
+ if (this.buffer.includes('\n')) {
1053
+ parts.push(`${ESC.DIM}${this.buffer.split('\n').length}L${ESC.RESET}`);
787
1054
  }
788
- return `${Math.round(value)}`;
789
- }
790
- visibleLength(value) {
791
- const ansiPattern = /\u001B\[[0-?]*[ -/]*[@-~]/g;
792
- return value.replace(ansiPattern, '').length;
1055
+ if (parts.length === 0) {
1056
+ return ''; // Empty status bar when idle
1057
+ }
1058
+ const joined = parts.join(`${ESC.DIM} · ${ESC.RESET}`);
1059
+ return joined.slice(0, maxWidth);
793
1060
  }
794
1061
  /**
795
- * Debug-only snapshot used by tests to assert rendered strings without
796
- * needing a TTY. Not used by production code.
1062
+ * Build mode controls line showing toggles and context info.
1063
+ * This is the BOTTOM line below the input area - Claude Code style layout with erosolar features.
1064
+ *
1065
+ * Layout: [toggles on left] ... [context info on right]
797
1066
  */
798
- getDebugUiSnapshot(width) {
799
- const cols = Math.max(8, width ?? this.getSize().cols);
800
- return {
801
- meta: this.buildMetaLines(cols - 2),
802
- controls: this.buildModeControls(cols),
803
- };
1067
+ buildModeControls(cols) {
1068
+ const maxWidth = cols - 2;
1069
+ // Use schema-defined colors for consistency
1070
+ const { green: GREEN, yellow: YELLOW, cyan: CYAN, magenta: MAGENTA, red: RED, dim: DIM, reset: R } = UI_COLORS;
1071
+ // Mode toggles with colors (following ModeControlsSchema)
1072
+ const toggles = [];
1073
+ // Edit mode (green=auto, yellow=ask) - per schema.editMode
1074
+ if (this.editMode === 'display-edits') {
1075
+ toggles.push(`${GREEN}⏵⏵ auto-edit${R}`);
1076
+ }
1077
+ else {
1078
+ toggles.push(`${YELLOW}⏸⏸ ask-first${R}`);
1079
+ }
1080
+ // Thinking mode (cyan when on) - per schema.thinkingMode
1081
+ toggles.push(this.thinkingEnabled ? `${CYAN}💭 think${R}` : `${DIM}○ think${R}`);
1082
+ // Verification (green when on) - per schema.verificationMode
1083
+ toggles.push(this.verificationEnabled ? `${GREEN}✓ verify${R}` : `${DIM}○ verify${R}`);
1084
+ // Auto-continue (magenta when on) - per schema.autoContinueMode
1085
+ toggles.push(this.autoContinueEnabled ? `${MAGENTA}↻ auto${R}` : `${DIM}○ auto${R}`);
1086
+ const leftPart = toggles.join(`${DIM} · ${R}`) + `${DIM} (⇧⇥)${R}`;
1087
+ // Context usage with color - per schema.contextUsage thresholds
1088
+ let rightPart = '';
1089
+ if (this.contextUsage !== null) {
1090
+ const rem = Math.max(0, 100 - this.contextUsage);
1091
+ // Thresholds: critical < 10%, warning < 25%
1092
+ if (rem < 10)
1093
+ rightPart = `${RED}⚠ ctx: ${rem}%${R}`;
1094
+ else if (rem < 25)
1095
+ rightPart = `${YELLOW}! ctx: ${rem}%${R}`;
1096
+ else
1097
+ rightPart = `${DIM}ctx: ${rem}%${R}`;
1098
+ }
1099
+ // Calculate visible lengths (strip ANSI)
1100
+ const strip = (s) => s.replace(/\x1b\[[0-9;]*m/g, '');
1101
+ const leftLen = strip(leftPart).length;
1102
+ const rightLen = strip(rightPart).length;
1103
+ if (leftLen + rightLen < maxWidth - 4) {
1104
+ return `${leftPart}${' '.repeat(maxWidth - leftLen - rightLen)}${rightPart}`;
1105
+ }
1106
+ if (rightLen > 0 && leftLen + 8 < maxWidth) {
1107
+ return `${leftPart} ${rightPart}`;
1108
+ }
1109
+ return leftPart;
804
1110
  }
805
1111
  /**
806
1112
  * Force a re-render
@@ -823,19 +1129,17 @@ export class TerminalInput extends EventEmitter {
823
1129
  handleResize() {
824
1130
  this.lastRenderContent = '';
825
1131
  this.lastRenderCursor = -1;
826
- this.resetStreamingRenderThrottle();
827
1132
  // Re-clamp pinned header rows to the new terminal height
828
1133
  this.setPinnedHeaderLines(this.pinnedTopRows);
829
- if (this.scrollRegionActive) {
830
- this.disableScrollRegion();
831
- this.enableScrollRegion();
832
- }
833
1134
  this.scheduleRender();
834
1135
  }
835
1136
  /**
836
1137
  * Register with display's output interceptor to position cursor correctly.
837
1138
  * When scroll region is active, output needs to go to the scroll region,
838
1139
  * not the protected bottom area where the input is rendered.
1140
+ *
1141
+ * NOTE: With scroll region properly set, content naturally stays within
1142
+ * the region boundaries - no cursor manipulation needed per-write.
839
1143
  */
840
1144
  registerOutputInterceptor(display) {
841
1145
  if (this.outputInterceptorCleanup) {
@@ -843,51 +1147,25 @@ export class TerminalInput extends EventEmitter {
843
1147
  }
844
1148
  this.outputInterceptorCleanup = display.registerOutputInterceptor({
845
1149
  beforeWrite: () => {
846
- // Move cursor to where content should continue in the scroll region.
847
- // This ensures streaming content goes above the pinned input area.
848
- if (this.scrollRegionActive) {
849
- this.write(ESC.TO(this.contentCursorRow, this.contentCursorCol));
850
- }
1150
+ // Scroll region handles content containment automatically
1151
+ // No per-write cursor manipulation needed
851
1152
  },
852
1153
  afterWrite: () => {
853
- // After writing, advance the content cursor.
854
- // We track row advancement; terminal handles column naturally.
855
- // Assume each write ends with cursor ready for next content.
856
- if (this.scrollRegionActive) {
857
- const { rows } = this.getSize();
858
- const scrollBottom = Math.max(this.pinnedTopRows + 1, rows - this.reservedLines);
859
- // Content scrolls when we hit bottom, so clamp to scrollBottom
860
- if (this.contentCursorRow < scrollBottom) {
861
- this.contentCursorRow++;
862
- }
863
- // Reset column to 1 (assuming newline at end of content)
864
- this.contentCursorCol = 1;
865
- }
1154
+ // No cursor manipulation needed
866
1155
  },
867
1156
  });
868
1157
  }
869
- /**
870
- * Reset content cursor to just below the banner (start of scroll region).
871
- */
872
- resetContentCursor() {
873
- this.contentCursorRow = Math.max(1, this.pinnedTopRows + 1);
874
- this.contentCursorCol = 1;
875
- }
876
- /**
877
- * Position content cursor at the bottom of scroll region (for initial streaming).
878
- */
879
- positionContentCursorAtBottom() {
880
- const { rows } = this.getSize();
881
- const scrollBottom = Math.max(this.pinnedTopRows + 1, rows - this.reservedLines);
882
- this.contentCursorRow = scrollBottom;
883
- this.contentCursorCol = 1;
884
- }
885
1158
  /**
886
1159
  * Dispose and clean up
887
1160
  */
888
1161
  dispose() {
889
1162
  if (this.disposed)
890
1163
  return;
1164
+ // Clean up streaming render timer
1165
+ if (this.streamingRenderTimer) {
1166
+ clearInterval(this.streamingRenderTimer);
1167
+ this.streamingRenderTimer = null;
1168
+ }
891
1169
  // Clean up output interceptor
892
1170
  if (this.outputInterceptorCleanup) {
893
1171
  this.outputInterceptorCleanup();
@@ -895,7 +1173,6 @@ export class TerminalInput extends EventEmitter {
895
1173
  }
896
1174
  this.disposed = true;
897
1175
  this.enabled = false;
898
- this.resetStreamingRenderThrottle();
899
1176
  this.disableScrollRegion();
900
1177
  this.disableBracketedPaste();
901
1178
  this.buffer = '';
@@ -1001,7 +1278,22 @@ export class TerminalInput extends EventEmitter {
1001
1278
  this.toggleEditMode();
1002
1279
  return true;
1003
1280
  }
1004
- this.insertText(' ');
1281
+ // Tab: toggle paste expansion if in placeholder, otherwise toggle thinking
1282
+ if (this.findPlaceholderAt(this.cursor)) {
1283
+ this.togglePasteExpansion();
1284
+ }
1285
+ else {
1286
+ this.toggleThinking();
1287
+ }
1288
+ return true;
1289
+ case 'escape':
1290
+ // Esc: interrupt if streaming, otherwise clear buffer
1291
+ if (this.mode === 'streaming') {
1292
+ this.emit('interrupt');
1293
+ }
1294
+ else if (this.buffer.length > 0) {
1295
+ this.clear();
1296
+ }
1005
1297
  return true;
1006
1298
  }
1007
1299
  return false;
@@ -1019,6 +1311,7 @@ export class TerminalInput extends EventEmitter {
1019
1311
  this.insertPlainText(chunk, insertPos);
1020
1312
  this.cursor = insertPos + chunk.length;
1021
1313
  this.emit('change', this.buffer);
1314
+ this.updateSuggestions();
1022
1315
  this.scheduleRender();
1023
1316
  }
1024
1317
  insertNewline() {
@@ -1043,6 +1336,7 @@ export class TerminalInput extends EventEmitter {
1043
1336
  this.cursor = Math.max(0, this.cursor - 1);
1044
1337
  }
1045
1338
  this.emit('change', this.buffer);
1339
+ this.updateSuggestions();
1046
1340
  this.scheduleRender();
1047
1341
  }
1048
1342
  deleteForward() {
@@ -1292,9 +1586,7 @@ export class TerminalInput extends EventEmitter {
1292
1586
  if (available <= 0)
1293
1587
  return;
1294
1588
  const chunk = clean.slice(0, available);
1295
- const isMultiline = isMultilinePaste(chunk);
1296
- const isShortMultiline = isMultiline && this.shouldInlineMultiline(chunk);
1297
- if (isMultiline && !isShortMultiline) {
1589
+ if (isMultilinePaste(chunk)) {
1298
1590
  this.insertPastePlaceholder(chunk);
1299
1591
  }
1300
1592
  else {
@@ -1314,7 +1606,6 @@ export class TerminalInput extends EventEmitter {
1314
1606
  return;
1315
1607
  this.applyScrollRegion();
1316
1608
  this.scrollRegionActive = true;
1317
- this.forceRender();
1318
1609
  }
1319
1610
  disableScrollRegion() {
1320
1611
  if (!this.scrollRegionActive)
@@ -1465,19 +1756,17 @@ export class TerminalInput extends EventEmitter {
1465
1756
  this.shiftPlaceholders(position, text.length);
1466
1757
  this.buffer = this.buffer.slice(0, position) + text + this.buffer.slice(position);
1467
1758
  }
1468
- shouldInlineMultiline(content) {
1469
- const lines = content.split('\n').length;
1470
- const maxInlineLines = 4;
1471
- const maxInlineChars = 240;
1472
- return lines <= maxInlineLines && content.length <= maxInlineChars;
1473
- }
1474
1759
  findPlaceholderAt(position) {
1475
1760
  return this.pastePlaceholders.find((ph) => position >= ph.start && position < ph.end) ?? null;
1476
1761
  }
1477
- buildPlaceholder(lineCount) {
1762
+ buildPlaceholder(summary) {
1478
1763
  const id = ++this.pasteCounter;
1479
- const plural = lineCount === 1 ? '' : 's';
1480
- const placeholder = `[Pasted text #${id} +${lineCount} line${plural}]`;
1764
+ const lang = summary.language ? ` ${summary.language.toUpperCase()}` : '';
1765
+ // Show first line preview (truncated)
1766
+ const preview = summary.preview.length > 30
1767
+ ? `${summary.preview.slice(0, 30)}...`
1768
+ : summary.preview;
1769
+ const placeholder = `[📋 #${id}${lang} ${summary.lineCount}L] "${preview}"`;
1481
1770
  return { id, placeholder };
1482
1771
  }
1483
1772
  insertPastePlaceholder(content) {
@@ -1485,21 +1774,67 @@ export class TerminalInput extends EventEmitter {
1485
1774
  if (available <= 0)
1486
1775
  return;
1487
1776
  const cleanContent = content.slice(0, available);
1488
- const lineCount = cleanContent.split('\n').length;
1489
- const { id, placeholder } = this.buildPlaceholder(lineCount);
1777
+ const summary = generatePasteSummary(cleanContent);
1778
+ // For short pastes (< 5 lines), show full content instead of placeholder
1779
+ if (summary.lineCount < 5) {
1780
+ const placeholder = this.findPlaceholderAt(this.cursor);
1781
+ const insertPos = placeholder && this.cursor > placeholder.start ? placeholder.end : this.cursor;
1782
+ this.insertPlainText(cleanContent, insertPos);
1783
+ this.cursor = insertPos + cleanContent.length;
1784
+ return;
1785
+ }
1786
+ const { id, placeholder } = this.buildPlaceholder(summary);
1490
1787
  const insertPos = this.cursor;
1491
1788
  this.shiftPlaceholders(insertPos, placeholder.length);
1492
1789
  this.pastePlaceholders.push({
1493
1790
  id,
1494
1791
  content: cleanContent,
1495
- lineCount,
1792
+ lineCount: summary.lineCount,
1496
1793
  placeholder,
1497
1794
  start: insertPos,
1498
1795
  end: insertPos + placeholder.length,
1796
+ summary,
1797
+ expanded: false,
1499
1798
  });
1500
1799
  this.buffer = this.buffer.slice(0, insertPos) + placeholder + this.buffer.slice(insertPos);
1501
1800
  this.cursor = insertPos + placeholder.length;
1502
1801
  }
1802
+ /**
1803
+ * Toggle expansion of a paste placeholder at the current cursor position.
1804
+ * When expanded, shows first 3 and last 2 lines of the content.
1805
+ */
1806
+ togglePasteExpansion() {
1807
+ const placeholder = this.findPlaceholderAt(this.cursor);
1808
+ if (!placeholder)
1809
+ return false;
1810
+ placeholder.expanded = !placeholder.expanded;
1811
+ // Update the placeholder text in buffer
1812
+ const newPlaceholder = placeholder.expanded
1813
+ ? this.buildExpandedPlaceholder(placeholder)
1814
+ : this.buildPlaceholder(placeholder.summary).placeholder;
1815
+ const lengthDiff = newPlaceholder.length - placeholder.placeholder.length;
1816
+ // Update buffer
1817
+ this.buffer =
1818
+ this.buffer.slice(0, placeholder.start) +
1819
+ newPlaceholder +
1820
+ this.buffer.slice(placeholder.end);
1821
+ // Update placeholder tracking
1822
+ placeholder.placeholder = newPlaceholder;
1823
+ placeholder.end = placeholder.start + newPlaceholder.length;
1824
+ // Shift other placeholders
1825
+ this.shiftPlaceholders(placeholder.end, lengthDiff, placeholder.id);
1826
+ this.scheduleRender();
1827
+ return true;
1828
+ }
1829
+ buildExpandedPlaceholder(ph) {
1830
+ const lines = ph.content.split('\n');
1831
+ const firstLines = lines.slice(0, 3).map(l => l.slice(0, 60)).join('\n');
1832
+ const lastLines = lines.length > 5
1833
+ ? '\n...\n' + lines.slice(-2).map(l => l.slice(0, 60)).join('\n')
1834
+ : '';
1835
+ const lang = ph.summary.language ? ` ${ph.summary.language.toUpperCase()}` : '';
1836
+ return `[📋 #${ph.id}${lang} ${ph.lineCount}L ▼]\n${firstLines}${lastLines}\n[/📋 #${ph.id}]`;
1837
+ }
1503
1838
  deletePlaceholder(placeholder) {
1504
1839
  const length = placeholder.end - placeholder.start;
1505
1840
  this.buffer = this.buffer.slice(0, placeholder.start) + this.buffer.slice(placeholder.end);
@@ -1507,11 +1842,7 @@ export class TerminalInput extends EventEmitter {
1507
1842
  this.shiftPlaceholders(placeholder.end, -length, placeholder.id);
1508
1843
  this.cursor = placeholder.start;
1509
1844
  }
1510
- updateContextUsage(value, autoCompactThreshold) {
1511
- if (typeof autoCompactThreshold === 'number' && Number.isFinite(autoCompactThreshold)) {
1512
- const boundedThreshold = Math.max(1, Math.min(100, Math.round(autoCompactThreshold)));
1513
- this.contextAutoCompactThreshold = boundedThreshold;
1514
- }
1845
+ updateContextUsage(value) {
1515
1846
  if (value === null || !Number.isFinite(value)) {
1516
1847
  this.contextUsage = null;
1517
1848
  }
@@ -1538,22 +1869,6 @@ export class TerminalInput extends EventEmitter {
1538
1869
  const next = this.editMode === 'display-edits' ? 'ask-permission' : 'display-edits';
1539
1870
  this.setEditMode(next);
1540
1871
  }
1541
- scheduleStreamingRender(delayMs) {
1542
- if (this.streamingRenderTimer)
1543
- return;
1544
- const wait = Math.max(16, delayMs);
1545
- this.streamingRenderTimer = setTimeout(() => {
1546
- this.streamingRenderTimer = null;
1547
- this.render();
1548
- }, wait);
1549
- }
1550
- resetStreamingRenderThrottle() {
1551
- if (this.streamingRenderTimer) {
1552
- clearTimeout(this.streamingRenderTimer);
1553
- this.streamingRenderTimer = null;
1554
- }
1555
- this.lastStreamingRender = 0;
1556
- }
1557
1872
  scheduleRender() {
1558
1873
  if (!this.canRender())
1559
1874
  return;