erosolar-cli 1.7.267 → 1.7.268

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 +153 -77
  252. package/dist/shell/terminalInput.d.ts.map +1 -1
  253. package/dist/shell/terminalInput.js +718 -489
  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,377 @@ 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();
211
- this.enableScrollRegion();
217
+ // Track streaming start time for elapsed display
218
+ this.streamingStartTime = Date.now();
219
+ // NO scroll regions - content flows naturally to terminal scrollback
220
+ this.pinnedTopRows = 0;
221
+ this.reservedLines = 0; // No reserved lines during streaming - content flows freely
222
+ // Disable any existing scroll region
223
+ this.disableScrollRegion();
224
+ // During streaming: NO re-rendering of input area
225
+ // Content flows naturally to stdout without interference
226
+ // The UI will be rendered after streaming ends
227
+ this.streamingRenderTimer = null;
212
228
  this.renderDirty = true;
213
- this.render();
214
229
  }
215
230
  else if (mode !== 'streaming' && prevMode === 'streaming') {
216
- // Streaming ended - render the input area
217
- this.resetStreamingRenderThrottle();
218
- this.enableScrollRegion();
219
- this.forceRender();
231
+ // Stop streaming render timer
232
+ if (this.streamingRenderTimer) {
233
+ clearInterval(this.streamingRenderTimer);
234
+ this.streamingRenderTimer = null;
235
+ }
236
+ // Reset streaming time
237
+ this.streamingStartTime = null;
238
+ this.pinnedTopRows = 0;
239
+ // Ensure no scroll region is active
240
+ this.disableScrollRegion();
241
+ // Reset flow mode tracking
242
+ this.flowModeRenderedLines = 0;
243
+ // Render input area using unified method (same as streaming, but normal mode)
244
+ writeLock.withLock(() => {
245
+ this.renderPinnedInputArea();
246
+ }, 'terminalInput.streamingEnd');
247
+ }
248
+ }
249
+ /**
250
+ * Update streaming status label (called by timer)
251
+ */
252
+ updateStreamingStatus() {
253
+ if (this.mode !== 'streaming' || !this.streamingStartTime)
254
+ return;
255
+ // Calculate elapsed time
256
+ const elapsed = Date.now() - this.streamingStartTime;
257
+ const seconds = Math.floor(elapsed / 1000);
258
+ const minutes = Math.floor(seconds / 60);
259
+ const secs = seconds % 60;
260
+ // Format elapsed time
261
+ let elapsedStr;
262
+ if (minutes > 0) {
263
+ elapsedStr = `${minutes}m ${secs}s`;
264
+ }
265
+ else {
266
+ elapsedStr = `${secs}s`;
267
+ }
268
+ // Update streaming label
269
+ this.streamingLabel = `Streaming ${elapsedStr}`;
270
+ }
271
+ /**
272
+ * Render input area - unified for streaming and normal modes.
273
+ *
274
+ * In streaming mode: renders at absolute bottom, uses cursor save/restore
275
+ * In normal mode: renders right after the banner (pinnedTopRows + 1)
276
+ */
277
+ renderPinnedInputArea() {
278
+ const { rows, cols } = this.getSize();
279
+ const maxWidth = Math.max(8, cols - 4);
280
+ const divider = renderDivider(cols - 2);
281
+ const isStreaming = this.mode === 'streaming';
282
+ // Wrap buffer into display lines (multi-line support)
283
+ const { lines, cursorLine, cursorCol } = this.wrapBuffer(maxWidth);
284
+ const availableForContent = Math.max(1, rows - 5); // Reserve 5 lines for UI
285
+ const maxVisible = Math.max(1, Math.min(this.config.maxLines, availableForContent));
286
+ const displayLines = Math.min(lines.length, maxVisible);
287
+ // Calculate display window (keep cursor visible)
288
+ let startLine = 0;
289
+ if (lines.length > displayLines) {
290
+ startLine = Math.max(0, cursorLine - displayLines + 1);
291
+ startLine = Math.min(startLine, lines.length - displayLines);
292
+ }
293
+ const visibleLines = lines.slice(startLine, startLine + displayLines);
294
+ const adjustedCursorLine = cursorLine - startLine;
295
+ // Calculate total height: status + (model info if set) + topDiv + input lines + bottomDiv + controls
296
+ const hasModelInfo = !!this.modelInfo;
297
+ const totalHeight = 4 + visibleLines.length + (hasModelInfo ? 1 : 0);
298
+ // Save cursor position during streaming (so content flow resumes correctly)
299
+ if (isStreaming) {
300
+ this.write(ESC.SAVE);
301
+ }
302
+ this.write(ESC.HIDE);
303
+ this.write(ESC.RESET);
304
+ // Calculate start row based on mode:
305
+ // - Streaming: absolute bottom (rows - totalHeight + 1)
306
+ // - Normal: right after content (contentEndRow + 1)
307
+ let currentRow;
308
+ if (isStreaming) {
309
+ currentRow = Math.max(1, rows - totalHeight + 1);
310
+ }
311
+ else {
312
+ // In normal mode, render right after content
313
+ // Use contentEndRow if set, otherwise use pinnedTopRows
314
+ const contentRow = this.contentEndRow > 0 ? this.contentEndRow : this.pinnedTopRows;
315
+ currentRow = Math.max(1, contentRow + 1);
316
+ }
317
+ let finalRow = currentRow;
318
+ let finalCol = 3;
319
+ // Clear from current position to end of screen to remove any "ghost" content
320
+ this.write(ESC.TO(currentRow, 1));
321
+ this.write(ESC.CLEAR_TO_END);
322
+ // Status bar
323
+ this.write(ESC.TO(currentRow, 1));
324
+ this.write(isStreaming ? this.buildStreamingStatusBar(cols) : this.buildStatusBar(cols));
325
+ currentRow++;
326
+ // Model info line (if set) - displayed below status, above input
327
+ if (hasModelInfo) {
328
+ const { dim: DIM, reset: R } = UI_COLORS;
329
+ this.write(ESC.TO(currentRow, 1));
330
+ // Build model info with context usage
331
+ let modelLine = `${DIM}${this.modelInfo}${R}`;
332
+ if (this.contextUsage !== null) {
333
+ const rem = Math.max(0, 100 - this.contextUsage);
334
+ if (rem < 10)
335
+ modelLine += ` ${UI_COLORS.red}⚠ ctx: ${rem}%${R}`;
336
+ else if (rem < 25)
337
+ modelLine += ` ${UI_COLORS.yellow}! ctx: ${rem}%${R}`;
338
+ else
339
+ modelLine += ` ${DIM}· ctx: ${rem}%${R}`;
340
+ }
341
+ this.write(modelLine);
342
+ currentRow++;
343
+ }
344
+ // Top divider
345
+ this.write(ESC.TO(currentRow, 1));
346
+ this.write(divider);
347
+ currentRow++;
348
+ // Input lines with background styling
349
+ for (let i = 0; i < visibleLines.length; i++) {
350
+ this.write(ESC.TO(currentRow, 1));
351
+ const line = visibleLines[i] ?? '';
352
+ const absoluteLineIdx = startLine + i;
353
+ const isFirstLine = absoluteLineIdx === 0;
354
+ const isCursorLine = i === adjustedCursorLine;
355
+ // Background
356
+ this.write(ESC.BG_DARK);
357
+ // Prompt prefix
358
+ this.write(ESC.DIM);
359
+ this.write(isFirstLine ? this.config.promptChar : this.config.continuationChar);
360
+ this.write(ESC.RESET);
361
+ this.write(ESC.BG_DARK);
362
+ if (isCursorLine) {
363
+ const col = Math.min(cursorCol, line.length);
364
+ const before = line.slice(0, col);
365
+ const at = col < line.length ? line[col] : ' ';
366
+ const after = col < line.length ? line.slice(col + 1) : '';
367
+ this.write(before);
368
+ this.write(ESC.REVERSE + ESC.BOLD);
369
+ this.write(at);
370
+ this.write(ESC.RESET + ESC.BG_DARK);
371
+ this.write(after);
372
+ finalRow = currentRow;
373
+ finalCol = this.config.promptChar.length + col + 1;
374
+ }
375
+ else {
376
+ this.write(line);
377
+ }
378
+ // Pad to edge
379
+ const lineLen = this.config.promptChar.length + line.length + (isCursorLine && cursorCol >= line.length ? 1 : 0);
380
+ const padding = Math.max(0, cols - lineLen - 1);
381
+ if (padding > 0)
382
+ this.write(' '.repeat(padding));
383
+ this.write(ESC.RESET);
384
+ currentRow++;
385
+ }
386
+ // Bottom divider
387
+ this.write(ESC.TO(currentRow, 1));
388
+ this.write(divider);
389
+ currentRow++;
390
+ // Mode controls line
391
+ this.write(ESC.TO(currentRow, 1));
392
+ this.write(this.buildModeControls(cols));
393
+ // Restore cursor position during streaming, or show cursor in normal mode
394
+ if (isStreaming) {
395
+ this.write(ESC.RESTORE);
396
+ }
397
+ else {
398
+ // Position cursor in input area
399
+ this.write(ESC.TO(finalRow, Math.min(finalCol, cols)));
400
+ this.write(ESC.SHOW);
220
401
  }
402
+ // Update reserved lines for scroll region calculations
403
+ this.updateReservedLines(totalHeight);
404
+ }
405
+ /**
406
+ * Render input area during streaming (alias for unified method)
407
+ */
408
+ renderStreamingInputArea() {
409
+ this.renderPinnedInputArea();
410
+ }
411
+ /**
412
+ * Enable or disable flow mode.
413
+ * In flow mode, the input renders immediately after content (wherever cursor is).
414
+ * When disabled, input renders at the absolute bottom of terminal.
415
+ */
416
+ setFlowMode(enabled) {
417
+ if (this.flowMode === enabled)
418
+ return;
419
+ this.flowMode = enabled;
420
+ this.renderDirty = true;
421
+ this.scheduleRender();
422
+ }
423
+ /**
424
+ * Check if flow mode is enabled.
425
+ */
426
+ isFlowMode() {
427
+ return this.flowMode;
428
+ }
429
+ /**
430
+ * Set the row where content ends (for idle mode positioning).
431
+ * Input area will render starting from this row + 1.
432
+ */
433
+ setContentEndRow(row) {
434
+ this.contentEndRow = Math.max(0, row);
435
+ this.renderDirty = true;
436
+ this.scheduleRender();
437
+ }
438
+ /**
439
+ * Set available slash commands for auto-complete suggestions.
440
+ */
441
+ setCommands(commands) {
442
+ this.commandSuggestions = commands;
443
+ this.updateSuggestions();
444
+ }
445
+ /**
446
+ * Update filtered suggestions based on current input.
447
+ */
448
+ updateSuggestions() {
449
+ const input = this.buffer.trim();
450
+ // Only show suggestions when input starts with "/"
451
+ if (!input.startsWith('/')) {
452
+ this.showSuggestions = false;
453
+ this.filteredSuggestions = [];
454
+ this.selectedSuggestionIndex = 0;
455
+ return;
456
+ }
457
+ const query = input.toLowerCase();
458
+ this.filteredSuggestions = this.commandSuggestions.filter(cmd => cmd.command.toLowerCase().startsWith(query) ||
459
+ cmd.command.toLowerCase().includes(query.slice(1)));
460
+ // Show suggestions if we have matches
461
+ this.showSuggestions = this.filteredSuggestions.length > 0;
462
+ // Keep selection in bounds
463
+ if (this.selectedSuggestionIndex >= this.filteredSuggestions.length) {
464
+ this.selectedSuggestionIndex = Math.max(0, this.filteredSuggestions.length - 1);
465
+ }
466
+ }
467
+ /**
468
+ * Select next suggestion (arrow down / tab).
469
+ */
470
+ selectNextSuggestion() {
471
+ if (!this.showSuggestions || this.filteredSuggestions.length === 0)
472
+ return;
473
+ this.selectedSuggestionIndex = (this.selectedSuggestionIndex + 1) % this.filteredSuggestions.length;
474
+ this.renderDirty = true;
475
+ this.scheduleRender();
476
+ }
477
+ /**
478
+ * Select previous suggestion (arrow up / shift+tab).
479
+ */
480
+ selectPrevSuggestion() {
481
+ if (!this.showSuggestions || this.filteredSuggestions.length === 0)
482
+ return;
483
+ this.selectedSuggestionIndex = this.selectedSuggestionIndex === 0
484
+ ? this.filteredSuggestions.length - 1
485
+ : this.selectedSuggestionIndex - 1;
486
+ this.renderDirty = true;
487
+ this.scheduleRender();
488
+ }
489
+ /**
490
+ * Accept current suggestion and insert into buffer.
491
+ */
492
+ acceptSuggestion() {
493
+ if (!this.showSuggestions || this.filteredSuggestions.length === 0)
494
+ return false;
495
+ const selected = this.filteredSuggestions[this.selectedSuggestionIndex];
496
+ if (!selected)
497
+ return false;
498
+ // Replace buffer with selected command
499
+ this.buffer = selected.command + ' ';
500
+ this.cursor = this.buffer.length;
501
+ this.showSuggestions = false;
502
+ this.renderDirty = true;
503
+ this.scheduleRender();
504
+ return true;
505
+ }
506
+ /**
507
+ * Check if suggestions are visible.
508
+ */
509
+ areSuggestionsVisible() {
510
+ return this.showSuggestions && this.filteredSuggestions.length > 0;
511
+ }
512
+ /**
513
+ * Update token count for metrics display
514
+ */
515
+ setTokensUsed(tokens) {
516
+ this.tokensUsed = tokens;
517
+ }
518
+ /**
519
+ * Toggle thinking/reasoning mode
520
+ */
521
+ toggleThinking() {
522
+ this.thinkingEnabled = !this.thinkingEnabled;
523
+ this.emit('thinkingToggle', this.thinkingEnabled);
524
+ this.scheduleRender();
525
+ }
526
+ /**
527
+ * Get thinking enabled state
528
+ */
529
+ isThinkingEnabled() {
530
+ return this.thinkingEnabled;
221
531
  }
222
532
  /**
223
533
  * Keep the top N rows pinned outside the scroll region (used for the launch banner).
224
534
  */
225
535
  setPinnedHeaderLines(count) {
226
- // No pinned header rows anymore; keep everything in the scroll region.
227
- if (this.pinnedTopRows !== 0) {
228
- this.pinnedTopRows = 0;
536
+ // Set pinned header rows (banner area that scroll region excludes)
537
+ if (this.pinnedTopRows !== count) {
538
+ this.pinnedTopRows = count;
229
539
  if (this.scrollRegionActive) {
230
540
  this.applyScrollRegion();
231
541
  }
232
542
  }
233
543
  }
544
+ /**
545
+ * Anchor prompt rendering near a specific row (inline layout). Pass null to
546
+ * restore the default bottom-aligned layout.
547
+ */
548
+ setInlineAnchor(row) {
549
+ if (row === null || row === undefined) {
550
+ this.inlineAnchorRow = null;
551
+ this.inlineLayout = false;
552
+ this.renderDirty = true;
553
+ this.render();
554
+ return;
555
+ }
556
+ const { rows } = this.getSize();
557
+ const clamped = Math.max(1, Math.min(Math.floor(row), rows));
558
+ this.inlineAnchorRow = clamped;
559
+ this.inlineLayout = true;
560
+ this.renderDirty = true;
561
+ this.render();
562
+ }
563
+ /**
564
+ * Provide a dynamic anchor callback. When set, the prompt will follow the
565
+ * output by re-evaluating the anchor before each render.
566
+ */
567
+ setInlineAnchorProvider(provider) {
568
+ this.anchorProvider = provider;
569
+ if (!provider) {
570
+ this.inlineLayout = false;
571
+ this.inlineAnchorRow = null;
572
+ this.renderDirty = true;
573
+ this.render();
574
+ return;
575
+ }
576
+ this.inlineLayout = true;
577
+ this.renderDirty = true;
578
+ this.render();
579
+ }
234
580
  /**
235
581
  * Get current mode
236
582
  */
@@ -340,37 +686,6 @@ export class TerminalInput extends EventEmitter {
340
686
  this.streamingLabel = next;
341
687
  this.scheduleRender();
342
688
  }
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
689
  /**
375
690
  * Keep mode toggles (verification/auto-continue) visible in the control bar.
376
691
  * Hotkey labels remain stable so the bar looks the same before/during streaming.
@@ -380,22 +695,26 @@ export class TerminalInput extends EventEmitter {
380
695
  const nextAutoContinue = !!options.autoContinueEnabled;
381
696
  const nextVerifyHotkey = options.verificationHotkey ?? this.verificationHotkey;
382
697
  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
698
  if (this.verificationEnabled === nextVerification &&
386
699
  this.autoContinueEnabled === nextAutoContinue &&
387
700
  this.verificationHotkey === nextVerifyHotkey &&
388
- this.autoContinueHotkey === nextAutoHotkey &&
389
- this.thinkingHotkey === nextThinkingHotkey &&
390
- this.thinkingModeLabel === nextThinkingLabel) {
701
+ this.autoContinueHotkey === nextAutoHotkey) {
391
702
  return;
392
703
  }
393
704
  this.verificationEnabled = nextVerification;
394
705
  this.autoContinueEnabled = nextAutoContinue;
395
706
  this.verificationHotkey = nextVerifyHotkey;
396
707
  this.autoContinueHotkey = nextAutoHotkey;
397
- this.thinkingHotkey = nextThinkingHotkey;
398
- this.thinkingModeLabel = nextThinkingLabel;
708
+ this.scheduleRender();
709
+ }
710
+ /**
711
+ * Set the model info string (e.g., "OpenAI · gpt-4")
712
+ * This is displayed persistently above the input area.
713
+ */
714
+ setModelInfo(info) {
715
+ if (this.modelInfo === info)
716
+ return;
717
+ this.modelInfo = info;
399
718
  this.scheduleRender();
400
719
  }
401
720
  /**
@@ -407,400 +726,301 @@ export class TerminalInput extends EventEmitter {
407
726
  this.streamingLabel = null;
408
727
  this.scheduleRender();
409
728
  }
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
729
  /**
424
730
  * Render the input area - Claude Code style with mode controls
425
731
  *
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.
732
+ * During streaming: NO rendering to avoid interference with content flow
733
+ * After streaming: Renders the full input area
429
734
  */
430
735
  render() {
431
736
  if (!this.canRender())
432
737
  return;
433
738
  if (this.isRendering)
434
739
  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
- }
740
+ // During streaming, skip all rendering - let content flow naturally
741
+ if (this.mode === 'streaming') {
742
+ return;
446
743
  }
447
744
  const shouldSkip = !this.renderDirty &&
448
745
  this.buffer === this.lastRenderContent &&
449
746
  this.cursor === this.lastRenderCursor;
450
747
  this.renderDirty = false;
451
- // Skip if nothing changed and no explicit refresh requested
748
+ // Skip if nothing changed (unless explicitly forced)
452
749
  if (shouldSkip) {
453
750
  return;
454
751
  }
455
- // If write lock is held, defer render to avoid race conditions
752
+ // If write lock is held, defer render
456
753
  if (writeLock.isLocked()) {
457
754
  writeLock.safeWrite(() => this.render());
458
755
  return;
459
756
  }
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
757
+ this.isRendering = true;
758
+ writeLock.lock('terminalInput.render');
759
+ try {
760
+ // Render input area at bottom (outside scroll region)
761
+ this.renderBottomPinned();
762
+ }
763
+ finally {
764
+ writeLock.unlock();
765
+ this.isRendering = false;
766
+ }
767
+ }
768
+ /**
769
+ * Render in flow mode - delegates to bottom-pinned for stability.
770
+ *
771
+ * Flow mode attempted inline rendering but caused duplicate renders
772
+ * due to unreliable cursor position tracking. Bottom-pinned is reliable.
773
+ */
774
+ renderFlowMode() {
775
+ // Use stable bottom-pinned approach
776
+ this.renderBottomPinned();
777
+ }
778
+ /**
779
+ * Render in bottom-pinned mode - Claude Code style with suggestions
780
+ *
781
+ * Works for both normal and streaming modes:
782
+ * - During streaming: saves/restores cursor position
783
+ * - Status bar shows streaming info or "Type a message"
784
+ *
785
+ * Layout when suggestions visible:
786
+ * - Top divider
787
+ * - Input line(s)
788
+ * - Bottom divider
789
+ * - Suggestions (command list)
790
+ *
791
+ * Layout when suggestions hidden:
792
+ * - Status bar (Ready/Streaming)
793
+ * - Top divider
794
+ * - Input line(s)
795
+ * - Bottom divider
796
+ * - Mode controls
797
+ */
798
+ renderBottomPinned() {
799
+ const { rows, cols } = this.getSize();
800
+ const maxWidth = Math.max(8, cols - 4);
801
+ const isStreaming = this.mode === 'streaming';
802
+ // Use unified pinned input area (works for both streaming and normal)
803
+ // Only use complex rendering when suggestions are visible
804
+ const hasSuggestions = !isStreaming && this.showSuggestions && this.filteredSuggestions.length > 0;
805
+ if (!hasSuggestions) {
806
+ this.renderPinnedInputArea();
807
+ return;
808
+ }
809
+ // Wrap buffer into display lines
810
+ const { lines, cursorLine, cursorCol } = this.wrapBuffer(maxWidth);
811
+ const availableForContent = Math.max(1, rows - 3);
812
+ const maxVisible = Math.max(1, Math.min(this.config.maxLines, availableForContent));
813
+ const displayLines = Math.min(lines.length, maxVisible);
814
+ // Calculate display window (keep cursor visible)
815
+ let startLine = 0;
816
+ if (lines.length > displayLines) {
817
+ startLine = Math.max(0, cursorLine - displayLines + 1);
818
+ startLine = Math.min(startLine, lines.length - displayLines);
819
+ }
820
+ const visibleLines = lines.slice(startLine, startLine + displayLines);
821
+ const adjustedCursorLine = cursorLine - startLine;
822
+ // Calculate suggestion display (not during streaming)
823
+ const suggestionsToShow = (!isStreaming && this.showSuggestions)
824
+ ? this.filteredSuggestions.slice(0, this.maxVisibleSuggestions)
825
+ : [];
826
+ const suggestionLines = suggestionsToShow.length;
827
+ this.write(ESC.HIDE);
828
+ this.write(ESC.RESET);
829
+ const divider = renderDivider(cols - 2);
830
+ // Calculate positions from absolute bottom
831
+ let currentRow;
832
+ if (suggestionLines > 0) {
833
+ // With suggestions: input area + dividers + suggestions
834
+ // Layout: [topDiv] [input] [bottomDiv] [suggestions...]
835
+ const totalHeight = 2 + visibleLines.length + suggestionLines; // 2 dividers
836
+ currentRow = Math.max(1, rows - totalHeight + 1);
837
+ this.updateReservedLines(totalHeight);
838
+ // Clear from current position to end of screen to remove any "ghost" content
839
+ this.write(ESC.TO(currentRow, 1));
840
+ this.write(ESC.CLEAR_TO_END);
841
+ // Top divider
497
842
  this.write(ESC.TO(currentRow, 1));
498
- this.write(ESC.CLEAR_LINE);
499
- const divider = renderDivider(cols - 2);
500
843
  this.write(divider);
501
- currentRow += 1;
502
- // Render input lines
844
+ currentRow++;
845
+ // Input lines
503
846
  let finalRow = currentRow;
504
847
  let finalCol = 3;
505
848
  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);
849
+ this.write(ESC.TO(currentRow, 1));
509
850
  const line = visibleLines[i] ?? '';
510
851
  const absoluteLineIdx = startLine + i;
511
852
  const isFirstLine = absoluteLineIdx === 0;
512
853
  const isCursorLine = i === adjustedCursorLine;
513
- // Background
514
- this.write(ESC.BG_DARK);
515
- // Prompt prefix
516
- this.write(ESC.DIM);
517
854
  this.write(isFirstLine ? this.config.promptChar : this.config.continuationChar);
518
- this.write(ESC.RESET);
519
- this.write(ESC.BG_DARK);
520
855
  if (isCursorLine) {
521
- // Render with block cursor
522
856
  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;
857
+ this.write(line.slice(0, col));
858
+ this.write(ESC.REVERSE);
859
+ this.write(col < line.length ? line[col] : ' ');
860
+ this.write(ESC.RESET);
861
+ this.write(line.slice(col + 1));
862
+ finalRow = currentRow;
532
863
  finalCol = this.config.promptChar.length + col + 1;
533
864
  }
534
865
  else {
535
866
  this.write(line);
536
867
  }
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);
868
+ currentRow++;
543
869
  }
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);
555
- }
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;
870
+ // Bottom divider
871
+ this.write(ESC.TO(currentRow, 1));
872
+ this.write(divider);
873
+ currentRow++;
874
+ // Suggestions (Claude Code style)
875
+ for (let i = 0; i < suggestionsToShow.length; i++) {
876
+ this.write(ESC.TO(currentRow, 1));
877
+ const suggestion = suggestionsToShow[i];
878
+ const isSelected = i === this.selectedSuggestionIndex;
879
+ // Indent and highlight selected
880
+ this.write(' ');
881
+ if (isSelected) {
882
+ this.write(ESC.REVERSE);
883
+ this.write(ESC.BOLD);
884
+ }
885
+ this.write(suggestion.command);
886
+ if (isSelected) {
887
+ this.write(ESC.RESET);
888
+ }
889
+ // Description (dimmed)
890
+ const descSpace = cols - suggestion.command.length - 8;
891
+ if (descSpace > 10 && suggestion.description) {
892
+ const desc = suggestion.description.slice(0, descSpace);
893
+ this.write(ESC.RESET);
894
+ this.write(ESC.DIM);
895
+ this.write(' ');
896
+ this.write(desc);
897
+ this.write(ESC.RESET);
898
+ }
899
+ currentRow++;
568
900
  }
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;
901
+ // Position cursor in input area
902
+ this.write(ESC.TO(finalRow, Math.min(finalCol, cols)));
579
903
  }
904
+ this.write(ESC.SHOW);
905
+ // Update state
906
+ this.lastRenderContent = this.buffer;
907
+ this.lastRenderCursor = this.cursor;
580
908
  }
581
909
  /**
582
- * Build one or more compact meta lines above the divider (thinking, status, usage).
583
- * During streaming, shows model line pinned above streaming info.
910
+ * Build status bar for streaming mode (shows elapsed time, queue count).
584
911
  */
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
- }
912
+ buildStreamingStatusBar(cols) {
913
+ const { green: GREEN, cyan: CYAN, dim: DIM, reset: R } = UI_COLORS;
914
+ // Streaming status with elapsed time
915
+ let elapsed = '0s';
916
+ if (this.streamingStartTime) {
917
+ const secs = Math.floor((Date.now() - this.streamingStartTime) / 1000);
918
+ const mins = Math.floor(secs / 60);
919
+ elapsed = mins > 0 ? `${mins}m ${secs % 60}s` : `${secs}s`;
920
+ }
921
+ let status = `${GREEN}● Streaming${R} ${elapsed}`;
922
+ // Queue indicator
642
923
  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));
647
- }
648
- return lines;
649
- }
650
- /**
651
- * Clear the reserved bottom block (meta + divider + input + controls) before repainting.
652
- */
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));
924
+ status += ` ${DIM}·${R} ${CYAN}${this.queue.length} queued${R}`;
659
925
  }
926
+ // Hint for typing
927
+ status += ` ${DIM}· type to queue message${R}`;
928
+ return status;
660
929
  }
661
930
  /**
662
- * Build Claude Code style mode controls line.
663
- * Combines streaming label + override status + main status for simultaneous display.
931
+ * Build status bar showing streaming/ready status and key info.
932
+ * This is the TOP line above the input area - minimal Claude Code style.
664
933
  */
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' });
934
+ buildStatusBar(cols) {
935
+ const maxWidth = cols - 2;
936
+ const parts = [];
937
+ // Streaming status with elapsed time (left side)
938
+ if (this.mode === 'streaming') {
939
+ let statusText = '● Streaming';
940
+ if (this.streamingStartTime) {
941
+ const elapsed = Math.floor((Date.now() - this.streamingStartTime) / 1000);
942
+ const mins = Math.floor(elapsed / 60);
943
+ const secs = elapsed % 60;
944
+ statusText += mins > 0 ? ` ${mins}m ${secs}s` : ` ${secs}s`;
945
+ }
946
+ parts.push(`\x1b[32m${statusText}\x1b[0m`); // Green
671
947
  }
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' });
690
- }
691
- if (this.buffer.includes('\n')) {
692
- const lineCount = this.buffer.split('\n').length;
693
- leftParts.push({ text: `${lineCount} lines`, tone: 'muted' });
948
+ // Queue indicator during streaming
949
+ if (this.mode === 'streaming' && this.queue.length > 0) {
950
+ parts.push(`\x1b[36mqueued: ${this.queue.length}\x1b[0m`); // Cyan
694
951
  }
952
+ // Paste indicator
695
953
  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
- });
954
+ const totalLines = this.pastePlaceholders.reduce((sum, p) => sum + p.lineCount, 0);
955
+ parts.push(`\x1b[36m📋 ${totalLines}L\x1b[0m`);
701
956
  }
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}`;
957
+ // Override/warning status
958
+ if (this.overrideStatusMessage) {
959
+ parts.push(`\x1b[33m⚠ ${this.overrideStatusMessage}\x1b[0m`); // Yellow
781
960
  }
782
- if (value >= 1_000_000) {
783
- return `${(value / 1_000_000).toFixed(1)}M`;
961
+ // If idle with empty buffer, show quick shortcuts
962
+ if (this.mode !== 'streaming' && this.buffer.length === 0 && parts.length === 0) {
963
+ return `${ESC.DIM}Type a message or / for commands${ESC.RESET}`;
784
964
  }
785
- if (value >= 1_000) {
786
- return `${(value / 1_000).toFixed(1)}k`;
965
+ // Multi-line indicator
966
+ if (this.buffer.includes('\n')) {
967
+ parts.push(`${ESC.DIM}${this.buffer.split('\n').length}L${ESC.RESET}`);
787
968
  }
788
- return `${Math.round(value)}`;
789
- }
790
- visibleLength(value) {
791
- const ansiPattern = /\u001B\[[0-?]*[ -/]*[@-~]/g;
792
- return value.replace(ansiPattern, '').length;
969
+ if (parts.length === 0) {
970
+ return ''; // Empty status bar when idle
971
+ }
972
+ const joined = parts.join(`${ESC.DIM} · ${ESC.RESET}`);
973
+ return joined.slice(0, maxWidth);
793
974
  }
794
975
  /**
795
- * Debug-only snapshot used by tests to assert rendered strings without
796
- * needing a TTY. Not used by production code.
976
+ * Build mode controls line showing toggles and context info.
977
+ * This is the BOTTOM line below the input area - Claude Code style layout with erosolar features.
978
+ *
979
+ * Layout: [toggles on left] ... [context info on right]
797
980
  */
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
- };
981
+ buildModeControls(cols) {
982
+ const maxWidth = cols - 2;
983
+ // Use schema-defined colors for consistency
984
+ const { green: GREEN, yellow: YELLOW, cyan: CYAN, magenta: MAGENTA, red: RED, dim: DIM, reset: R } = UI_COLORS;
985
+ // Mode toggles with colors (following ModeControlsSchema)
986
+ const toggles = [];
987
+ // Edit mode (green=auto, yellow=ask) - per schema.editMode
988
+ if (this.editMode === 'display-edits') {
989
+ toggles.push(`${GREEN}⏵⏵ auto-edit${R}`);
990
+ }
991
+ else {
992
+ toggles.push(`${YELLOW}⏸⏸ ask-first${R}`);
993
+ }
994
+ // Thinking mode (cyan when on) - per schema.thinkingMode
995
+ toggles.push(this.thinkingEnabled ? `${CYAN}💭 think${R}` : `${DIM}○ think${R}`);
996
+ // Verification (green when on) - per schema.verificationMode
997
+ toggles.push(this.verificationEnabled ? `${GREEN}✓ verify${R}` : `${DIM}○ verify${R}`);
998
+ // Auto-continue (magenta when on) - per schema.autoContinueMode
999
+ toggles.push(this.autoContinueEnabled ? `${MAGENTA}↻ auto${R}` : `${DIM}○ auto${R}`);
1000
+ const leftPart = toggles.join(`${DIM} · ${R}`) + `${DIM} (⇧⇥)${R}`;
1001
+ // Context usage with color - per schema.contextUsage thresholds
1002
+ let rightPart = '';
1003
+ if (this.contextUsage !== null) {
1004
+ const rem = Math.max(0, 100 - this.contextUsage);
1005
+ // Thresholds: critical < 10%, warning < 25%
1006
+ if (rem < 10)
1007
+ rightPart = `${RED}⚠ ctx: ${rem}%${R}`;
1008
+ else if (rem < 25)
1009
+ rightPart = `${YELLOW}! ctx: ${rem}%${R}`;
1010
+ else
1011
+ rightPart = `${DIM}ctx: ${rem}%${R}`;
1012
+ }
1013
+ // Calculate visible lengths (strip ANSI)
1014
+ const strip = (s) => s.replace(/\x1b\[[0-9;]*m/g, '');
1015
+ const leftLen = strip(leftPart).length;
1016
+ const rightLen = strip(rightPart).length;
1017
+ if (leftLen + rightLen < maxWidth - 4) {
1018
+ return `${leftPart}${' '.repeat(maxWidth - leftLen - rightLen)}${rightPart}`;
1019
+ }
1020
+ if (rightLen > 0 && leftLen + 8 < maxWidth) {
1021
+ return `${leftPart} ${rightPart}`;
1022
+ }
1023
+ return leftPart;
804
1024
  }
805
1025
  /**
806
1026
  * Force a re-render
@@ -823,19 +1043,17 @@ export class TerminalInput extends EventEmitter {
823
1043
  handleResize() {
824
1044
  this.lastRenderContent = '';
825
1045
  this.lastRenderCursor = -1;
826
- this.resetStreamingRenderThrottle();
827
1046
  // Re-clamp pinned header rows to the new terminal height
828
1047
  this.setPinnedHeaderLines(this.pinnedTopRows);
829
- if (this.scrollRegionActive) {
830
- this.disableScrollRegion();
831
- this.enableScrollRegion();
832
- }
833
1048
  this.scheduleRender();
834
1049
  }
835
1050
  /**
836
1051
  * Register with display's output interceptor to position cursor correctly.
837
1052
  * When scroll region is active, output needs to go to the scroll region,
838
1053
  * not the protected bottom area where the input is rendered.
1054
+ *
1055
+ * NOTE: With scroll region properly set, content naturally stays within
1056
+ * the region boundaries - no cursor manipulation needed per-write.
839
1057
  */
840
1058
  registerOutputInterceptor(display) {
841
1059
  if (this.outputInterceptorCleanup) {
@@ -843,51 +1061,25 @@ export class TerminalInput extends EventEmitter {
843
1061
  }
844
1062
  this.outputInterceptorCleanup = display.registerOutputInterceptor({
845
1063
  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
- }
1064
+ // Scroll region handles content containment automatically
1065
+ // No per-write cursor manipulation needed
851
1066
  },
852
1067
  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
- }
1068
+ // No cursor manipulation needed
866
1069
  },
867
1070
  });
868
1071
  }
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
1072
  /**
886
1073
  * Dispose and clean up
887
1074
  */
888
1075
  dispose() {
889
1076
  if (this.disposed)
890
1077
  return;
1078
+ // Clean up streaming render timer
1079
+ if (this.streamingRenderTimer) {
1080
+ clearInterval(this.streamingRenderTimer);
1081
+ this.streamingRenderTimer = null;
1082
+ }
891
1083
  // Clean up output interceptor
892
1084
  if (this.outputInterceptorCleanup) {
893
1085
  this.outputInterceptorCleanup();
@@ -895,7 +1087,6 @@ export class TerminalInput extends EventEmitter {
895
1087
  }
896
1088
  this.disposed = true;
897
1089
  this.enabled = false;
898
- this.resetStreamingRenderThrottle();
899
1090
  this.disableScrollRegion();
900
1091
  this.disableBracketedPaste();
901
1092
  this.buffer = '';
@@ -1001,7 +1192,22 @@ export class TerminalInput extends EventEmitter {
1001
1192
  this.toggleEditMode();
1002
1193
  return true;
1003
1194
  }
1004
- this.insertText(' ');
1195
+ // Tab: toggle paste expansion if in placeholder, otherwise toggle thinking
1196
+ if (this.findPlaceholderAt(this.cursor)) {
1197
+ this.togglePasteExpansion();
1198
+ }
1199
+ else {
1200
+ this.toggleThinking();
1201
+ }
1202
+ return true;
1203
+ case 'escape':
1204
+ // Esc: interrupt if streaming, otherwise clear buffer
1205
+ if (this.mode === 'streaming') {
1206
+ this.emit('interrupt');
1207
+ }
1208
+ else if (this.buffer.length > 0) {
1209
+ this.clear();
1210
+ }
1005
1211
  return true;
1006
1212
  }
1007
1213
  return false;
@@ -1019,6 +1225,7 @@ export class TerminalInput extends EventEmitter {
1019
1225
  this.insertPlainText(chunk, insertPos);
1020
1226
  this.cursor = insertPos + chunk.length;
1021
1227
  this.emit('change', this.buffer);
1228
+ this.updateSuggestions();
1022
1229
  this.scheduleRender();
1023
1230
  }
1024
1231
  insertNewline() {
@@ -1043,6 +1250,7 @@ export class TerminalInput extends EventEmitter {
1043
1250
  this.cursor = Math.max(0, this.cursor - 1);
1044
1251
  }
1045
1252
  this.emit('change', this.buffer);
1253
+ this.updateSuggestions();
1046
1254
  this.scheduleRender();
1047
1255
  }
1048
1256
  deleteForward() {
@@ -1292,9 +1500,7 @@ export class TerminalInput extends EventEmitter {
1292
1500
  if (available <= 0)
1293
1501
  return;
1294
1502
  const chunk = clean.slice(0, available);
1295
- const isMultiline = isMultilinePaste(chunk);
1296
- const isShortMultiline = isMultiline && this.shouldInlineMultiline(chunk);
1297
- if (isMultiline && !isShortMultiline) {
1503
+ if (isMultilinePaste(chunk)) {
1298
1504
  this.insertPastePlaceholder(chunk);
1299
1505
  }
1300
1506
  else {
@@ -1314,7 +1520,6 @@ export class TerminalInput extends EventEmitter {
1314
1520
  return;
1315
1521
  this.applyScrollRegion();
1316
1522
  this.scrollRegionActive = true;
1317
- this.forceRender();
1318
1523
  }
1319
1524
  disableScrollRegion() {
1320
1525
  if (!this.scrollRegionActive)
@@ -1465,19 +1670,17 @@ export class TerminalInput extends EventEmitter {
1465
1670
  this.shiftPlaceholders(position, text.length);
1466
1671
  this.buffer = this.buffer.slice(0, position) + text + this.buffer.slice(position);
1467
1672
  }
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
1673
  findPlaceholderAt(position) {
1475
1674
  return this.pastePlaceholders.find((ph) => position >= ph.start && position < ph.end) ?? null;
1476
1675
  }
1477
- buildPlaceholder(lineCount) {
1676
+ buildPlaceholder(summary) {
1478
1677
  const id = ++this.pasteCounter;
1479
- const plural = lineCount === 1 ? '' : 's';
1480
- const placeholder = `[Pasted text #${id} +${lineCount} line${plural}]`;
1678
+ const lang = summary.language ? ` ${summary.language.toUpperCase()}` : '';
1679
+ // Show first line preview (truncated)
1680
+ const preview = summary.preview.length > 30
1681
+ ? `${summary.preview.slice(0, 30)}...`
1682
+ : summary.preview;
1683
+ const placeholder = `[📋 #${id}${lang} ${summary.lineCount}L] "${preview}"`;
1481
1684
  return { id, placeholder };
1482
1685
  }
1483
1686
  insertPastePlaceholder(content) {
@@ -1485,21 +1688,67 @@ export class TerminalInput extends EventEmitter {
1485
1688
  if (available <= 0)
1486
1689
  return;
1487
1690
  const cleanContent = content.slice(0, available);
1488
- const lineCount = cleanContent.split('\n').length;
1489
- const { id, placeholder } = this.buildPlaceholder(lineCount);
1691
+ const summary = generatePasteSummary(cleanContent);
1692
+ // For short pastes (< 5 lines), show full content instead of placeholder
1693
+ if (summary.lineCount < 5) {
1694
+ const placeholder = this.findPlaceholderAt(this.cursor);
1695
+ const insertPos = placeholder && this.cursor > placeholder.start ? placeholder.end : this.cursor;
1696
+ this.insertPlainText(cleanContent, insertPos);
1697
+ this.cursor = insertPos + cleanContent.length;
1698
+ return;
1699
+ }
1700
+ const { id, placeholder } = this.buildPlaceholder(summary);
1490
1701
  const insertPos = this.cursor;
1491
1702
  this.shiftPlaceholders(insertPos, placeholder.length);
1492
1703
  this.pastePlaceholders.push({
1493
1704
  id,
1494
1705
  content: cleanContent,
1495
- lineCount,
1706
+ lineCount: summary.lineCount,
1496
1707
  placeholder,
1497
1708
  start: insertPos,
1498
1709
  end: insertPos + placeholder.length,
1710
+ summary,
1711
+ expanded: false,
1499
1712
  });
1500
1713
  this.buffer = this.buffer.slice(0, insertPos) + placeholder + this.buffer.slice(insertPos);
1501
1714
  this.cursor = insertPos + placeholder.length;
1502
1715
  }
1716
+ /**
1717
+ * Toggle expansion of a paste placeholder at the current cursor position.
1718
+ * When expanded, shows first 3 and last 2 lines of the content.
1719
+ */
1720
+ togglePasteExpansion() {
1721
+ const placeholder = this.findPlaceholderAt(this.cursor);
1722
+ if (!placeholder)
1723
+ return false;
1724
+ placeholder.expanded = !placeholder.expanded;
1725
+ // Update the placeholder text in buffer
1726
+ const newPlaceholder = placeholder.expanded
1727
+ ? this.buildExpandedPlaceholder(placeholder)
1728
+ : this.buildPlaceholder(placeholder.summary).placeholder;
1729
+ const lengthDiff = newPlaceholder.length - placeholder.placeholder.length;
1730
+ // Update buffer
1731
+ this.buffer =
1732
+ this.buffer.slice(0, placeholder.start) +
1733
+ newPlaceholder +
1734
+ this.buffer.slice(placeholder.end);
1735
+ // Update placeholder tracking
1736
+ placeholder.placeholder = newPlaceholder;
1737
+ placeholder.end = placeholder.start + newPlaceholder.length;
1738
+ // Shift other placeholders
1739
+ this.shiftPlaceholders(placeholder.end, lengthDiff, placeholder.id);
1740
+ this.scheduleRender();
1741
+ return true;
1742
+ }
1743
+ buildExpandedPlaceholder(ph) {
1744
+ const lines = ph.content.split('\n');
1745
+ const firstLines = lines.slice(0, 3).map(l => l.slice(0, 60)).join('\n');
1746
+ const lastLines = lines.length > 5
1747
+ ? '\n...\n' + lines.slice(-2).map(l => l.slice(0, 60)).join('\n')
1748
+ : '';
1749
+ const lang = ph.summary.language ? ` ${ph.summary.language.toUpperCase()}` : '';
1750
+ return `[📋 #${ph.id}${lang} ${ph.lineCount}L ▼]\n${firstLines}${lastLines}\n[/📋 #${ph.id}]`;
1751
+ }
1503
1752
  deletePlaceholder(placeholder) {
1504
1753
  const length = placeholder.end - placeholder.start;
1505
1754
  this.buffer = this.buffer.slice(0, placeholder.start) + this.buffer.slice(placeholder.end);
@@ -1507,11 +1756,7 @@ export class TerminalInput extends EventEmitter {
1507
1756
  this.shiftPlaceholders(placeholder.end, -length, placeholder.id);
1508
1757
  this.cursor = placeholder.start;
1509
1758
  }
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
- }
1759
+ updateContextUsage(value) {
1515
1760
  if (value === null || !Number.isFinite(value)) {
1516
1761
  this.contextUsage = null;
1517
1762
  }
@@ -1538,22 +1783,6 @@ export class TerminalInput extends EventEmitter {
1538
1783
  const next = this.editMode === 'display-edits' ? 'ask-permission' : 'display-edits';
1539
1784
  this.setEditMode(next);
1540
1785
  }
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
1786
  scheduleRender() {
1558
1787
  if (!this.canRender())
1559
1788
  return;