erosolar-cli 1.7.283 → 1.7.285

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (323) hide show
  1. package/README.md +148 -24
  2. package/dist/alpha-zero/agentWrapper.d.ts +84 -0
  3. package/dist/alpha-zero/agentWrapper.d.ts.map +1 -0
  4. package/dist/alpha-zero/agentWrapper.js +171 -0
  5. package/dist/alpha-zero/agentWrapper.js.map +1 -0
  6. package/dist/alpha-zero/codeEvaluator.d.ts +25 -0
  7. package/dist/alpha-zero/codeEvaluator.d.ts.map +1 -0
  8. package/dist/alpha-zero/codeEvaluator.js +273 -0
  9. package/dist/alpha-zero/codeEvaluator.js.map +1 -0
  10. package/dist/alpha-zero/competitiveRunner.d.ts +66 -0
  11. package/dist/alpha-zero/competitiveRunner.d.ts.map +1 -0
  12. package/dist/alpha-zero/competitiveRunner.js +224 -0
  13. package/dist/alpha-zero/competitiveRunner.js.map +1 -0
  14. package/dist/alpha-zero/index.d.ts +67 -0
  15. package/dist/alpha-zero/index.d.ts.map +1 -0
  16. package/dist/alpha-zero/index.js +99 -0
  17. package/dist/alpha-zero/index.js.map +1 -0
  18. package/dist/alpha-zero/introspection.d.ts +128 -0
  19. package/dist/alpha-zero/introspection.d.ts.map +1 -0
  20. package/dist/alpha-zero/introspection.js +300 -0
  21. package/dist/alpha-zero/introspection.js.map +1 -0
  22. package/dist/alpha-zero/metricsTracker.d.ts +71 -0
  23. package/dist/alpha-zero/metricsTracker.d.ts.map +1 -0
  24. package/dist/{core → alpha-zero}/metricsTracker.js +5 -2
  25. package/dist/alpha-zero/metricsTracker.js.map +1 -0
  26. package/dist/alpha-zero/security/core.d.ts +125 -0
  27. package/dist/alpha-zero/security/core.d.ts.map +1 -0
  28. package/dist/alpha-zero/security/core.js +271 -0
  29. package/dist/alpha-zero/security/core.js.map +1 -0
  30. package/dist/alpha-zero/security/google.d.ts +125 -0
  31. package/dist/alpha-zero/security/google.d.ts.map +1 -0
  32. package/dist/alpha-zero/security/google.js +311 -0
  33. package/dist/alpha-zero/security/google.js.map +1 -0
  34. package/dist/alpha-zero/security/googleLoader.d.ts +17 -0
  35. package/dist/alpha-zero/security/googleLoader.d.ts.map +1 -0
  36. package/dist/alpha-zero/security/googleLoader.js +41 -0
  37. package/dist/alpha-zero/security/googleLoader.js.map +1 -0
  38. package/dist/alpha-zero/security/index.d.ts +29 -0
  39. package/dist/alpha-zero/security/index.d.ts.map +1 -0
  40. package/dist/alpha-zero/security/index.js +32 -0
  41. package/dist/alpha-zero/security/index.js.map +1 -0
  42. package/dist/alpha-zero/security/simulation.d.ts +124 -0
  43. package/dist/alpha-zero/security/simulation.d.ts.map +1 -0
  44. package/dist/alpha-zero/security/simulation.js +277 -0
  45. package/dist/alpha-zero/security/simulation.js.map +1 -0
  46. package/dist/alpha-zero/selfModification.d.ts +109 -0
  47. package/dist/alpha-zero/selfModification.d.ts.map +1 -0
  48. package/dist/alpha-zero/selfModification.js +233 -0
  49. package/dist/alpha-zero/selfModification.js.map +1 -0
  50. package/dist/alpha-zero/types.d.ts +170 -0
  51. package/dist/alpha-zero/types.d.ts.map +1 -0
  52. package/dist/alpha-zero/types.js +31 -0
  53. package/dist/alpha-zero/types.js.map +1 -0
  54. package/dist/bin/erosolar.js +0 -1
  55. package/dist/bin/erosolar.js.map +1 -1
  56. package/dist/capabilities/agentSpawningCapability.d.ts.map +1 -1
  57. package/dist/capabilities/agentSpawningCapability.js +31 -56
  58. package/dist/capabilities/agentSpawningCapability.js.map +1 -1
  59. package/dist/capabilities/securityTestingCapability.d.ts +13 -0
  60. package/dist/capabilities/securityTestingCapability.d.ts.map +1 -0
  61. package/dist/capabilities/securityTestingCapability.js +25 -0
  62. package/dist/capabilities/securityTestingCapability.js.map +1 -0
  63. package/dist/contracts/agent-schemas.json +15 -0
  64. package/dist/contracts/tools.schema.json +9 -0
  65. package/dist/core/agent.d.ts +2 -2
  66. package/dist/core/agent.d.ts.map +1 -1
  67. package/dist/core/agent.js.map +1 -1
  68. package/dist/core/aiFlowOptimizer.d.ts +26 -0
  69. package/dist/core/aiFlowOptimizer.d.ts.map +1 -0
  70. package/dist/core/aiFlowOptimizer.js +31 -0
  71. package/dist/core/aiFlowOptimizer.js.map +1 -0
  72. package/dist/core/aiOptimizationEngine.d.ts +158 -0
  73. package/dist/core/aiOptimizationEngine.d.ts.map +1 -0
  74. package/dist/core/aiOptimizationEngine.js +428 -0
  75. package/dist/core/aiOptimizationEngine.js.map +1 -0
  76. package/dist/core/aiOptimizationIntegration.d.ts +93 -0
  77. package/dist/core/aiOptimizationIntegration.d.ts.map +1 -0
  78. package/dist/core/aiOptimizationIntegration.js +250 -0
  79. package/dist/core/aiOptimizationIntegration.js.map +1 -0
  80. package/dist/core/customCommands.d.ts +0 -1
  81. package/dist/core/customCommands.d.ts.map +1 -1
  82. package/dist/core/customCommands.js +0 -3
  83. package/dist/core/customCommands.js.map +1 -1
  84. package/dist/core/enhancedErrorRecovery.d.ts +100 -0
  85. package/dist/core/enhancedErrorRecovery.d.ts.map +1 -0
  86. package/dist/core/enhancedErrorRecovery.js +345 -0
  87. package/dist/core/enhancedErrorRecovery.js.map +1 -0
  88. package/dist/core/hooksSystem.d.ts +65 -0
  89. package/dist/core/hooksSystem.d.ts.map +1 -0
  90. package/dist/core/hooksSystem.js +273 -0
  91. package/dist/core/hooksSystem.js.map +1 -0
  92. package/dist/core/memorySystem.d.ts +48 -0
  93. package/dist/core/memorySystem.d.ts.map +1 -0
  94. package/dist/core/memorySystem.js +271 -0
  95. package/dist/core/memorySystem.js.map +1 -0
  96. package/dist/core/toolPreconditions.d.ts.map +1 -1
  97. package/dist/core/toolPreconditions.js +14 -0
  98. package/dist/core/toolPreconditions.js.map +1 -1
  99. package/dist/core/toolRuntime.d.ts +1 -22
  100. package/dist/core/toolRuntime.d.ts.map +1 -1
  101. package/dist/core/toolRuntime.js +5 -0
  102. package/dist/core/toolRuntime.js.map +1 -1
  103. package/dist/core/toolValidation.d.ts.map +1 -1
  104. package/dist/core/toolValidation.js +3 -14
  105. package/dist/core/toolValidation.js.map +1 -1
  106. package/dist/core/unified/errors.d.ts +189 -0
  107. package/dist/core/unified/errors.d.ts.map +1 -0
  108. package/dist/core/unified/errors.js +497 -0
  109. package/dist/core/unified/errors.js.map +1 -0
  110. package/dist/core/unified/index.d.ts +19 -0
  111. package/dist/core/unified/index.d.ts.map +1 -0
  112. package/dist/core/unified/index.js +68 -0
  113. package/dist/core/unified/index.js.map +1 -0
  114. package/dist/core/unified/schema.d.ts +101 -0
  115. package/dist/core/unified/schema.d.ts.map +1 -0
  116. package/dist/core/unified/schema.js +350 -0
  117. package/dist/core/unified/schema.js.map +1 -0
  118. package/dist/core/unified/toolRuntime.d.ts +179 -0
  119. package/dist/core/unified/toolRuntime.d.ts.map +1 -0
  120. package/dist/core/unified/toolRuntime.js +517 -0
  121. package/dist/core/unified/toolRuntime.js.map +1 -0
  122. package/dist/core/unified/tools.d.ts +127 -0
  123. package/dist/core/unified/tools.d.ts.map +1 -0
  124. package/dist/core/unified/tools.js +1333 -0
  125. package/dist/core/unified/tools.js.map +1 -0
  126. package/dist/core/unified/types.d.ts +352 -0
  127. package/dist/core/unified/types.d.ts.map +1 -0
  128. package/dist/core/unified/types.js +12 -0
  129. package/dist/core/unified/types.js.map +1 -0
  130. package/dist/core/unified/version.d.ts +209 -0
  131. package/dist/core/unified/version.d.ts.map +1 -0
  132. package/dist/core/unified/version.js +454 -0
  133. package/dist/core/unified/version.js.map +1 -0
  134. package/dist/core/validationRunner.d.ts +3 -1
  135. package/dist/core/validationRunner.d.ts.map +1 -1
  136. package/dist/core/validationRunner.js.map +1 -1
  137. package/dist/headless/headlessApp.d.ts.map +1 -1
  138. package/dist/headless/headlessApp.js +0 -21
  139. package/dist/headless/headlessApp.js.map +1 -1
  140. package/dist/mcp/sseClient.d.ts.map +1 -1
  141. package/dist/mcp/sseClient.js +18 -9
  142. package/dist/mcp/sseClient.js.map +1 -1
  143. package/dist/plugins/tools/build/buildPlugin.d.ts +6 -0
  144. package/dist/plugins/tools/build/buildPlugin.d.ts.map +1 -1
  145. package/dist/plugins/tools/build/buildPlugin.js +10 -4
  146. package/dist/plugins/tools/build/buildPlugin.js.map +1 -1
  147. package/dist/plugins/tools/nodeDefaults.d.ts.map +1 -1
  148. package/dist/plugins/tools/nodeDefaults.js +2 -0
  149. package/dist/plugins/tools/nodeDefaults.js.map +1 -1
  150. package/dist/plugins/tools/security/securityPlugin.d.ts +3 -0
  151. package/dist/plugins/tools/security/securityPlugin.d.ts.map +1 -0
  152. package/dist/plugins/tools/security/securityPlugin.js +12 -0
  153. package/dist/plugins/tools/security/securityPlugin.js.map +1 -0
  154. package/dist/runtime/agentSession.d.ts +2 -2
  155. package/dist/runtime/agentSession.d.ts.map +1 -1
  156. package/dist/runtime/agentSession.js +2 -2
  157. package/dist/runtime/agentSession.js.map +1 -1
  158. package/dist/security/active-stack-security.d.ts +112 -0
  159. package/dist/security/active-stack-security.d.ts.map +1 -0
  160. package/dist/security/active-stack-security.js +296 -0
  161. package/dist/security/active-stack-security.js.map +1 -0
  162. package/dist/security/advanced-persistence-research.d.ts +92 -0
  163. package/dist/security/advanced-persistence-research.d.ts.map +1 -0
  164. package/dist/security/advanced-persistence-research.js +195 -0
  165. package/dist/security/advanced-persistence-research.js.map +1 -0
  166. package/dist/security/advanced-targeting.d.ts +119 -0
  167. package/dist/security/advanced-targeting.d.ts.map +1 -0
  168. package/dist/security/advanced-targeting.js +233 -0
  169. package/dist/security/advanced-targeting.js.map +1 -0
  170. package/dist/security/assessment/vulnerabilityAssessment.d.ts +104 -0
  171. package/dist/security/assessment/vulnerabilityAssessment.d.ts.map +1 -0
  172. package/dist/security/assessment/vulnerabilityAssessment.js +315 -0
  173. package/dist/security/assessment/vulnerabilityAssessment.js.map +1 -0
  174. package/dist/security/authorization/securityAuthorization.d.ts +88 -0
  175. package/dist/security/authorization/securityAuthorization.d.ts.map +1 -0
  176. package/dist/security/authorization/securityAuthorization.js +172 -0
  177. package/dist/security/authorization/securityAuthorization.js.map +1 -0
  178. package/dist/security/comprehensive-targeting.d.ts +85 -0
  179. package/dist/security/comprehensive-targeting.d.ts.map +1 -0
  180. package/dist/security/comprehensive-targeting.js +438 -0
  181. package/dist/security/comprehensive-targeting.js.map +1 -0
  182. package/dist/security/global-security-integration.d.ts +91 -0
  183. package/dist/security/global-security-integration.d.ts.map +1 -0
  184. package/dist/security/global-security-integration.js +218 -0
  185. package/dist/security/global-security-integration.js.map +1 -0
  186. package/dist/security/index.d.ts +38 -0
  187. package/dist/security/index.d.ts.map +1 -0
  188. package/dist/security/index.js +47 -0
  189. package/dist/security/index.js.map +1 -0
  190. package/dist/security/persistence-analyzer.d.ts +56 -0
  191. package/dist/security/persistence-analyzer.d.ts.map +1 -0
  192. package/dist/security/persistence-analyzer.js +187 -0
  193. package/dist/security/persistence-analyzer.js.map +1 -0
  194. package/dist/security/persistence-cli.d.ts +36 -0
  195. package/dist/security/persistence-cli.d.ts.map +1 -0
  196. package/dist/security/persistence-cli.js +160 -0
  197. package/dist/security/persistence-cli.js.map +1 -0
  198. package/dist/security/persistence-research.d.ts +92 -0
  199. package/dist/security/persistence-research.d.ts.map +1 -0
  200. package/dist/security/persistence-research.js +364 -0
  201. package/dist/security/persistence-research.js.map +1 -0
  202. package/dist/security/research/persistenceResearch.d.ts +97 -0
  203. package/dist/security/research/persistenceResearch.d.ts.map +1 -0
  204. package/dist/security/research/persistenceResearch.js +282 -0
  205. package/dist/security/research/persistenceResearch.js.map +1 -0
  206. package/dist/security/security-integration.d.ts +74 -0
  207. package/dist/security/security-integration.d.ts.map +1 -0
  208. package/dist/security/security-integration.js +137 -0
  209. package/dist/security/security-integration.js.map +1 -0
  210. package/dist/security/security-testing-framework.d.ts +112 -0
  211. package/dist/security/security-testing-framework.d.ts.map +1 -0
  212. package/dist/security/security-testing-framework.js +364 -0
  213. package/dist/security/security-testing-framework.js.map +1 -0
  214. package/dist/security/simulation/attackSimulation.d.ts +93 -0
  215. package/dist/security/simulation/attackSimulation.d.ts.map +1 -0
  216. package/dist/security/simulation/attackSimulation.js +341 -0
  217. package/dist/security/simulation/attackSimulation.js.map +1 -0
  218. package/dist/security/strategic-operations.d.ts +100 -0
  219. package/dist/security/strategic-operations.d.ts.map +1 -0
  220. package/dist/security/strategic-operations.js +276 -0
  221. package/dist/security/strategic-operations.js.map +1 -0
  222. package/dist/security/tool-security-wrapper.d.ts +58 -0
  223. package/dist/security/tool-security-wrapper.d.ts.map +1 -0
  224. package/dist/security/tool-security-wrapper.js +156 -0
  225. package/dist/security/tool-security-wrapper.js.map +1 -0
  226. package/dist/shell/claudeCodeStreamHandler.d.ts +145 -0
  227. package/dist/shell/claudeCodeStreamHandler.d.ts.map +1 -0
  228. package/dist/shell/claudeCodeStreamHandler.js +322 -0
  229. package/dist/shell/claudeCodeStreamHandler.js.map +1 -0
  230. package/dist/shell/inputQueueManager.d.ts +144 -0
  231. package/dist/shell/inputQueueManager.d.ts.map +1 -0
  232. package/dist/shell/inputQueueManager.js +290 -0
  233. package/dist/shell/inputQueueManager.js.map +1 -0
  234. package/dist/shell/interactiveShell.d.ts +7 -17
  235. package/dist/shell/interactiveShell.d.ts.map +1 -1
  236. package/dist/shell/interactiveShell.js +159 -218
  237. package/dist/shell/interactiveShell.js.map +1 -1
  238. package/dist/shell/metricsTracker.d.ts +60 -0
  239. package/dist/shell/metricsTracker.d.ts.map +1 -0
  240. package/dist/shell/metricsTracker.js +119 -0
  241. package/dist/shell/metricsTracker.js.map +1 -0
  242. package/dist/shell/shellApp.d.ts +0 -2
  243. package/dist/shell/shellApp.d.ts.map +1 -1
  244. package/dist/shell/shellApp.js +9 -40
  245. package/dist/shell/shellApp.js.map +1 -1
  246. package/dist/shell/streamingOutputManager.d.ts +115 -0
  247. package/dist/shell/streamingOutputManager.d.ts.map +1 -0
  248. package/dist/shell/streamingOutputManager.js +225 -0
  249. package/dist/shell/streamingOutputManager.js.map +1 -0
  250. package/dist/shell/systemPrompt.d.ts.map +1 -1
  251. package/dist/shell/systemPrompt.js +4 -1
  252. package/dist/shell/systemPrompt.js.map +1 -1
  253. package/dist/shell/terminalInput.d.ts +186 -78
  254. package/dist/shell/terminalInput.d.ts.map +1 -1
  255. package/dist/shell/terminalInput.js +926 -494
  256. package/dist/shell/terminalInput.js.map +1 -1
  257. package/dist/shell/terminalInputAdapter.d.ts +35 -28
  258. package/dist/shell/terminalInputAdapter.d.ts.map +1 -1
  259. package/dist/shell/terminalInputAdapter.js +50 -26
  260. package/dist/shell/terminalInputAdapter.js.map +1 -1
  261. package/dist/subagents/taskRunner.d.ts +1 -7
  262. package/dist/subagents/taskRunner.d.ts.map +1 -1
  263. package/dist/subagents/taskRunner.js +47 -180
  264. package/dist/subagents/taskRunner.js.map +1 -1
  265. package/dist/tools/securityTools.d.ts +22 -0
  266. package/dist/tools/securityTools.d.ts.map +1 -0
  267. package/dist/tools/securityTools.js +448 -0
  268. package/dist/tools/securityTools.js.map +1 -0
  269. package/dist/ui/ShellUIAdapter.d.ts.map +1 -1
  270. package/dist/ui/ShellUIAdapter.js +12 -13
  271. package/dist/ui/ShellUIAdapter.js.map +1 -1
  272. package/dist/ui/display.d.ts +43 -22
  273. package/dist/ui/display.d.ts.map +1 -1
  274. package/dist/ui/display.js +283 -136
  275. package/dist/ui/display.js.map +1 -1
  276. package/dist/ui/persistentPrompt.d.ts +50 -0
  277. package/dist/ui/persistentPrompt.d.ts.map +1 -0
  278. package/dist/ui/persistentPrompt.js +92 -0
  279. package/dist/ui/persistentPrompt.js.map +1 -0
  280. package/dist/ui/terminalUISchema.d.ts +195 -0
  281. package/dist/ui/terminalUISchema.d.ts.map +1 -0
  282. package/dist/ui/terminalUISchema.js +113 -0
  283. package/dist/ui/terminalUISchema.js.map +1 -0
  284. package/dist/ui/theme.d.ts.map +1 -1
  285. package/dist/ui/theme.js +8 -6
  286. package/dist/ui/theme.js.map +1 -1
  287. package/dist/ui/toolDisplay.d.ts +158 -0
  288. package/dist/ui/toolDisplay.d.ts.map +1 -1
  289. package/dist/ui/toolDisplay.js +348 -0
  290. package/dist/ui/toolDisplay.js.map +1 -1
  291. package/dist/ui/unified/layout.d.ts +0 -1
  292. package/dist/ui/unified/layout.d.ts.map +1 -1
  293. package/dist/ui/unified/layout.js +25 -15
  294. package/dist/ui/unified/layout.js.map +1 -1
  295. package/package.json +1 -1
  296. package/scripts/deploy-security-capabilities.js +178 -0
  297. package/dist/core/hooks.d.ts +0 -113
  298. package/dist/core/hooks.d.ts.map +0 -1
  299. package/dist/core/hooks.js +0 -267
  300. package/dist/core/hooks.js.map +0 -1
  301. package/dist/core/metricsTracker.d.ts +0 -122
  302. package/dist/core/metricsTracker.d.ts.map +0 -1
  303. package/dist/core/metricsTracker.js.map +0 -1
  304. package/dist/core/securityAssessment.d.ts +0 -91
  305. package/dist/core/securityAssessment.d.ts.map +0 -1
  306. package/dist/core/securityAssessment.js +0 -580
  307. package/dist/core/securityAssessment.js.map +0 -1
  308. package/dist/core/verification.d.ts +0 -137
  309. package/dist/core/verification.d.ts.map +0 -1
  310. package/dist/core/verification.js +0 -323
  311. package/dist/core/verification.js.map +0 -1
  312. package/dist/subagents/agentConfig.d.ts +0 -27
  313. package/dist/subagents/agentConfig.d.ts.map +0 -1
  314. package/dist/subagents/agentConfig.js +0 -89
  315. package/dist/subagents/agentConfig.js.map +0 -1
  316. package/dist/subagents/agentRegistry.d.ts +0 -33
  317. package/dist/subagents/agentRegistry.d.ts.map +0 -1
  318. package/dist/subagents/agentRegistry.js +0 -162
  319. package/dist/subagents/agentRegistry.js.map +0 -1
  320. package/dist/utils/frontmatter.d.ts +0 -10
  321. package/dist/utils/frontmatter.d.ts.map +0 -1
  322. package/dist/utils/frontmatter.js +0 -78
  323. package/dist/utils/frontmatter.js.map +0 -1
@@ -3,18 +3,16 @@
3
3
  *
4
4
  * Design principles:
5
5
  * - Single source of truth for input state
6
- * - One bottom-pinned chat box for the entire session (no inline anchors)
7
6
  * - Native bracketed paste support (no heuristics)
8
7
  * - Clean cursor model with render-time wrapping
9
8
  * - State machine for different input modes
10
9
  * - No readline dependency for display
11
10
  */
12
11
  import { EventEmitter } from 'node:events';
13
- import { isMultilinePaste } from '../core/multilinePasteHandler.js';
12
+ import { isMultilinePaste, generatePasteSummary } from '../core/multilinePasteHandler.js';
14
13
  import { writeLock } from '../ui/writeLock.js';
15
- import { renderDivider, renderStatusLine } from '../ui/unified/layout.js';
16
- import { isStreamingMode } from '../ui/globalWriteLock.js';
17
- import { formatThinking } from '../ui/toolDisplay.js';
14
+ import { renderDivider } from '../ui/unified/layout.js';
15
+ import { UI_COLORS, DEFAULT_UI_CONFIG, } from '../ui/terminalUISchema.js';
18
16
  // ANSI escape codes
19
17
  const ESC = {
20
18
  // Cursor control
@@ -24,6 +22,9 @@ const ESC = {
24
22
  SHOW: '\x1b[?25h',
25
23
  TO: (row, col) => `\x1b[${row};${col}H`,
26
24
  TO_COL: (col) => `\x1b[${col}G`,
25
+ // Screen control
26
+ CLEAR_SCREEN: '\x1b[2J',
27
+ HOME: '\x1b[H',
27
28
  // Line control
28
29
  CLEAR_LINE: '\x1b[2K',
29
30
  CLEAR_TO_END: '\x1b[0J',
@@ -69,11 +70,6 @@ export class TerminalInput extends EventEmitter {
69
70
  statusMessage = null;
70
71
  overrideStatusMessage = null; // Secondary status (warnings, etc.)
71
72
  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
73
  reservedLines = 2;
78
74
  scrollRegionActive = false;
79
75
  lastRenderContent = '';
@@ -81,37 +77,49 @@ export class TerminalInput extends EventEmitter {
81
77
  renderDirty = false;
82
78
  isRendering = false;
83
79
  pinnedTopRows = 0;
80
+ inlineAnchorRow = null;
81
+ inlineLayout = false;
82
+ anchorProvider = null;
83
+ // Flow mode: when true, renders inline after content (no absolute positioning)
84
+ flowMode = true;
85
+ flowModeRenderedLines = 0; // Track lines rendered for clearing
86
+ contentEndRow = 0; // Row where content ends (for idle mode positioning)
87
+ // Command suggestions (Claude Code style auto-complete)
88
+ commandSuggestions = [];
89
+ filteredSuggestions = [];
90
+ selectedSuggestionIndex = 0;
91
+ showSuggestions = false;
92
+ maxVisibleSuggestions = 10;
84
93
  // Lifecycle
85
94
  disposed = false;
86
95
  enabled = true;
87
96
  contextUsage = null;
88
- contextAutoCompactThreshold = 90;
89
- // Track current content row in scroll region (starts at top, moves down)
90
- contentRow = 1;
91
- thinkingModeLabel = null;
92
97
  editMode = 'display-edits';
93
98
  verificationEnabled = true;
94
99
  autoContinueEnabled = false;
95
100
  verificationHotkey = 'alt+v';
96
101
  autoContinueHotkey = 'alt+c';
97
- thinkingHotkey = '/thinking';
98
- modelLabel = null;
99
- providerLabel = null;
100
102
  // Output interceptor cleanup
101
103
  outputInterceptorCleanup;
102
- // Streaming render throttle
103
- lastStreamingRender = 0;
104
- streamingRenderInterval = 250; // ms between renders during streaming
104
+ // Metrics tracking for status bar
105
+ streamingStartTime = null;
106
+ tokensUsed = 0;
107
+ thinkingEnabled = true;
108
+ modelInfo = null; // Provider · Model info
109
+ // Streaming input area render timer (updates elapsed time display)
105
110
  streamingRenderTimer = null;
111
+ // Unified UI initialization flag
112
+ unifiedUIInitialized = false;
106
113
  constructor(writeStream = process.stdout, config = {}) {
107
114
  super();
108
115
  this.out = writeStream;
116
+ // Use schema defaults for configuration consistency
109
117
  this.config = {
110
- maxLines: config.maxLines ?? 1000,
111
- maxLength: config.maxLength ?? 10000,
118
+ maxLines: config.maxLines ?? DEFAULT_UI_CONFIG.inputArea.maxLines,
119
+ maxLength: config.maxLength ?? DEFAULT_UI_CONFIG.inputArea.maxLength,
112
120
  maxQueueSize: config.maxQueueSize ?? 100,
113
- promptChar: config.promptChar ?? '> ',
114
- continuationChar: config.continuationChar ?? '│ ',
121
+ promptChar: config.promptChar ?? DEFAULT_UI_CONFIG.inputArea.promptChar,
122
+ continuationChar: config.continuationChar ?? DEFAULT_UI_CONFIG.inputArea.continuationChar,
115
123
  };
116
124
  }
117
125
  // ===========================================================================
@@ -190,46 +198,594 @@ export class TerminalInput extends EventEmitter {
190
198
  if (handled)
191
199
  return;
192
200
  }
201
+ // Handle '?' for help hint (if buffer is empty)
202
+ if (str === '?' && this.buffer.length === 0) {
203
+ this.emit('showHelp');
204
+ return;
205
+ }
193
206
  // Insert printable characters
194
207
  if (str && !key?.ctrl && !key?.meta) {
195
208
  this.insertText(str);
196
209
  }
197
210
  }
211
+ // Banner content to write on init (set via setBannerContent before initializeUnifiedUI)
212
+ bannerContent = null;
213
+ /**
214
+ * Set banner content to be written when unified UI initializes.
215
+ */
216
+ setBannerContent(content) {
217
+ this.bannerContent = content;
218
+ }
219
+ /**
220
+ * Initialize the unified UI system immediately.
221
+ * Clears screen, writes banner, renders input area immediately below.
222
+ * This creates a compact layout on launch (no empty space).
223
+ * Scroll region is set up later when streaming starts.
224
+ */
225
+ initializeUnifiedUI() {
226
+ if (this.unifiedUIInitialized) {
227
+ return;
228
+ }
229
+ // Reserve lines for input area (used later when scroll region is set up)
230
+ this.pinnedTopRows = 0;
231
+ this.reservedLines = 6; // status + model + divider + input + divider + controls
232
+ // Hide cursor during setup
233
+ this.write(ESC.HIDE);
234
+ // Clear screen
235
+ this.write(ESC.HOME);
236
+ this.write(ESC.CLEAR_SCREEN);
237
+ // Position cursor at row 1
238
+ this.write(ESC.TO(1, 1));
239
+ // Write banner as first content
240
+ let currentRow = 1;
241
+ if (this.bannerContent) {
242
+ process.stdout.write(this.bannerContent + '\n');
243
+ // Count banner lines
244
+ currentRow += this.bannerContent.split('\n').length;
245
+ }
246
+ // Mark unified UI as initialized
247
+ this.unifiedUIInitialized = true;
248
+ // Render input area immediately after banner (not at bottom)
249
+ this.renderInlineInputArea(currentRow);
250
+ // Show cursor
251
+ this.write(ESC.SHOW);
252
+ }
253
+ /**
254
+ * Render input area at a specific row (inline, not pinned to bottom).
255
+ * Used on launch for compact layout.
256
+ */
257
+ renderInlineInputArea(startRow) {
258
+ const { cols } = this.getSize();
259
+ const divider = '─'.repeat(cols - 1);
260
+ // Move to start row
261
+ this.write(ESC.TO(startRow, 1));
262
+ // Status bar
263
+ process.stdout.write(this.buildStatusBar(cols) + '\n');
264
+ // Model info line (if set)
265
+ if (this.modelInfo) {
266
+ const { dim: DIM, reset: R } = UI_COLORS;
267
+ let modelLine = `${DIM}${this.modelInfo}${R}`;
268
+ if (this.contextUsage !== null) {
269
+ const rem = Math.max(0, 100 - this.contextUsage);
270
+ if (rem < 10)
271
+ modelLine += ` ${UI_COLORS.red}⚠ ctx: ${rem}%${R}`;
272
+ else if (rem < 25)
273
+ modelLine += ` ${UI_COLORS.yellow}! ctx: ${rem}%${R}`;
274
+ else
275
+ modelLine += ` ${DIM}· ctx: ${rem}%${R}`;
276
+ }
277
+ process.stdout.write(modelLine + '\n');
278
+ }
279
+ // Top divider
280
+ process.stdout.write(divider + '\n');
281
+ // Input line with prompt
282
+ process.stdout.write(ESC.BG_DARK + ESC.DIM + this.config.promptChar + ESC.RESET);
283
+ process.stdout.write(ESC.BG_DARK + ' '.repeat(Math.max(0, cols - this.config.promptChar.length - 1)) + ESC.RESET + '\n');
284
+ // Bottom divider
285
+ process.stdout.write(divider + '\n');
286
+ // Mode controls
287
+ process.stdout.write(this.buildModeControls(cols) + '\n');
288
+ // Position cursor in input area
289
+ this.write(ESC.TO(startRow + (this.modelInfo ? 3 : 2), this.config.promptChar.length + 1));
290
+ }
198
291
  /**
199
292
  * Set the input mode
200
293
  *
201
- * Streaming keeps the scroll region active so the prompt/status stay pinned
202
- * below the streaming output. When streaming ends, we refresh the input area.
294
+ * Streaming mode disables scroll region and lets content flow naturally.
295
+ * The input area will be re-rendered after streaming ends at wherever
296
+ * the cursor is (below the streamed content).
203
297
  */
204
298
  setMode(mode) {
205
299
  const prevMode = this.mode;
206
300
  this.mode = mode;
207
301
  if (mode === 'streaming' && prevMode !== 'streaming') {
208
- // Keep scroll region active so status/prompt stay pinned while streaming
209
- this.resetStreamingRenderThrottle();
210
- this.enableScrollRegion();
302
+ // Track streaming start time for elapsed display
303
+ this.streamingStartTime = Date.now();
304
+ const { rows } = this.getSize();
305
+ // Ensure unified UI is initialized (if not already done on launch)
306
+ if (!this.unifiedUIInitialized) {
307
+ this.initializeUnifiedUI();
308
+ }
309
+ // Set up scroll region to reserve bottom for persistent input area
310
+ this.pinnedTopRows = 0;
311
+ this.reservedLines = 6; // status + model + divider + input + divider + controls
312
+ // Ensure scroll region is enabled (may have been initialized already)
313
+ if (!this.scrollRegionActive) {
314
+ const contentBottomRow = Math.max(1, rows - this.reservedLines);
315
+ this.write(ESC.TO(contentBottomRow, 1));
316
+ this.enableScrollRegion();
317
+ }
318
+ // Render bottom input area
319
+ this.renderBottomInputArea();
320
+ // Start timer to update bottom input area (updates elapsed time)
321
+ this.streamingRenderTimer = setInterval(() => {
322
+ if (this.mode === 'streaming') {
323
+ this.updateStreamingStatus();
324
+ this.renderBottomInputArea();
325
+ }
326
+ }, 1000);
211
327
  this.renderDirty = true;
212
- this.render();
213
328
  }
214
329
  else if (mode !== 'streaming' && prevMode === 'streaming') {
215
- // Streaming ended - render the input area
216
- this.resetStreamingRenderThrottle();
330
+ // Stop streaming render timer
331
+ if (this.streamingRenderTimer) {
332
+ clearInterval(this.streamingRenderTimer);
333
+ this.streamingRenderTimer = null;
334
+ }
335
+ // Reset streaming time
336
+ this.streamingStartTime = null;
337
+ // Keep scroll region active for consistent bottom-pinned UI
338
+ // (scroll region reserves bottom for input area in all modes)
339
+ // Reset flow mode tracking
340
+ this.flowModeRenderedLines = 0;
341
+ // Render using unified bottom input area (same layout as streaming)
342
+ writeLock.withLock(() => {
343
+ this.renderBottomInputArea();
344
+ }, 'terminalInput.streamingEnd');
345
+ }
346
+ }
347
+ /**
348
+ * Update streaming status label (called by timer)
349
+ */
350
+ updateStreamingStatus() {
351
+ if (this.mode !== 'streaming' || !this.streamingStartTime)
352
+ return;
353
+ // Calculate elapsed time
354
+ const elapsed = Date.now() - this.streamingStartTime;
355
+ const seconds = Math.floor(elapsed / 1000);
356
+ const minutes = Math.floor(seconds / 60);
357
+ const secs = seconds % 60;
358
+ // Format elapsed time
359
+ let elapsedStr;
360
+ if (minutes > 0) {
361
+ elapsedStr = `${minutes}m ${secs}s`;
362
+ }
363
+ else {
364
+ elapsedStr = `${secs}s`;
365
+ }
366
+ // Update streaming label
367
+ this.streamingLabel = `Streaming ${elapsedStr}`;
368
+ }
369
+ /**
370
+ * Render input area - unified for streaming and normal modes.
371
+ *
372
+ * In streaming mode: renders at absolute bottom, uses cursor save/restore
373
+ * In normal mode: renders right after the banner (pinnedTopRows + 1)
374
+ */
375
+ renderPinnedInputArea() {
376
+ const { rows, cols } = this.getSize();
377
+ const maxWidth = Math.max(8, cols - 4);
378
+ const divider = renderDivider(cols - 2);
379
+ const isStreaming = this.mode === 'streaming';
380
+ // Wrap buffer into display lines (multi-line support)
381
+ const { lines, cursorLine, cursorCol } = this.wrapBuffer(maxWidth);
382
+ const availableForContent = Math.max(1, rows - 5); // Reserve 5 lines for UI
383
+ const maxVisible = Math.max(1, Math.min(this.config.maxLines, availableForContent));
384
+ const displayLines = Math.min(lines.length, maxVisible);
385
+ // Calculate display window (keep cursor visible)
386
+ let startLine = 0;
387
+ if (lines.length > displayLines) {
388
+ startLine = Math.max(0, cursorLine - displayLines + 1);
389
+ startLine = Math.min(startLine, lines.length - displayLines);
390
+ }
391
+ const visibleLines = lines.slice(startLine, startLine + displayLines);
392
+ const adjustedCursorLine = cursorLine - startLine;
393
+ // Calculate total height: status + (model info if set) + topDiv + input lines + bottomDiv + controls
394
+ const hasModelInfo = !!this.modelInfo;
395
+ const totalHeight = 4 + visibleLines.length + (hasModelInfo ? 1 : 0);
396
+ // Save cursor position during streaming (so content flow resumes correctly)
397
+ if (isStreaming) {
398
+ this.write(ESC.SAVE);
399
+ }
400
+ this.write(ESC.HIDE);
401
+ this.write(ESC.RESET);
402
+ // Calculate start row based on mode:
403
+ // - Streaming: absolute bottom (rows - totalHeight + 1)
404
+ // - Normal: right after content (contentEndRow + 1)
405
+ let currentRow;
406
+ if (isStreaming) {
407
+ currentRow = Math.max(1, rows - totalHeight + 1);
408
+ }
409
+ else {
410
+ // In normal mode, render right after content
411
+ // Use contentEndRow if set, otherwise use pinnedTopRows
412
+ const contentRow = this.contentEndRow > 0 ? this.contentEndRow : this.pinnedTopRows;
413
+ currentRow = Math.max(1, contentRow + 1);
414
+ }
415
+ let finalRow = currentRow;
416
+ let finalCol = 3;
417
+ // Clear from current position to end of screen to remove any "ghost" content
418
+ this.write(ESC.TO(currentRow, 1));
419
+ this.write(ESC.CLEAR_TO_END);
420
+ // Status bar
421
+ this.write(ESC.TO(currentRow, 1));
422
+ this.write(isStreaming ? this.buildStreamingStatusBar(cols) : this.buildStatusBar(cols));
423
+ currentRow++;
424
+ // Model info line (if set) - displayed below status, above input
425
+ if (hasModelInfo) {
426
+ const { dim: DIM, reset: R } = UI_COLORS;
427
+ this.write(ESC.TO(currentRow, 1));
428
+ // Build model info with context usage
429
+ let modelLine = `${DIM}${this.modelInfo}${R}`;
430
+ if (this.contextUsage !== null) {
431
+ const rem = Math.max(0, 100 - this.contextUsage);
432
+ if (rem < 10)
433
+ modelLine += ` ${UI_COLORS.red}⚠ ctx: ${rem}%${R}`;
434
+ else if (rem < 25)
435
+ modelLine += ` ${UI_COLORS.yellow}! ctx: ${rem}%${R}`;
436
+ else
437
+ modelLine += ` ${DIM}· ctx: ${rem}%${R}`;
438
+ }
439
+ this.write(modelLine);
440
+ currentRow++;
441
+ }
442
+ // Top divider
443
+ this.write(ESC.TO(currentRow, 1));
444
+ this.write(divider);
445
+ currentRow++;
446
+ // Input lines with background styling
447
+ for (let i = 0; i < visibleLines.length; i++) {
448
+ this.write(ESC.TO(currentRow, 1));
449
+ const line = visibleLines[i] ?? '';
450
+ const absoluteLineIdx = startLine + i;
451
+ const isFirstLine = absoluteLineIdx === 0;
452
+ const isCursorLine = i === adjustedCursorLine;
453
+ // Background
454
+ this.write(ESC.BG_DARK);
455
+ // Prompt prefix
456
+ this.write(ESC.DIM);
457
+ this.write(isFirstLine ? this.config.promptChar : this.config.continuationChar);
458
+ this.write(ESC.RESET);
459
+ this.write(ESC.BG_DARK);
460
+ if (isCursorLine) {
461
+ const col = Math.min(cursorCol, line.length);
462
+ const before = line.slice(0, col);
463
+ const at = col < line.length ? line[col] : ' ';
464
+ const after = col < line.length ? line.slice(col + 1) : '';
465
+ this.write(before);
466
+ this.write(ESC.REVERSE + ESC.BOLD);
467
+ this.write(at);
468
+ this.write(ESC.RESET + ESC.BG_DARK);
469
+ this.write(after);
470
+ finalRow = currentRow;
471
+ finalCol = this.config.promptChar.length + col + 1;
472
+ }
473
+ else {
474
+ this.write(line);
475
+ }
476
+ // Pad to edge
477
+ const lineLen = this.config.promptChar.length + line.length + (isCursorLine && cursorCol >= line.length ? 1 : 0);
478
+ const padding = Math.max(0, cols - lineLen - 1);
479
+ if (padding > 0)
480
+ this.write(' '.repeat(padding));
481
+ this.write(ESC.RESET);
482
+ currentRow++;
483
+ }
484
+ // Bottom divider
485
+ this.write(ESC.TO(currentRow, 1));
486
+ this.write(divider);
487
+ currentRow++;
488
+ // Mode controls line
489
+ this.write(ESC.TO(currentRow, 1));
490
+ this.write(this.buildModeControls(cols));
491
+ // Restore cursor position during streaming, or show cursor in normal mode
492
+ if (isStreaming) {
493
+ this.write(ESC.RESTORE);
494
+ }
495
+ else {
496
+ // Position cursor in input area
497
+ this.write(ESC.TO(finalRow, Math.min(finalCol, cols)));
498
+ this.write(ESC.SHOW);
499
+ }
500
+ // Update reserved lines for scroll region calculations
501
+ this.updateReservedLines(totalHeight);
502
+ }
503
+ /**
504
+ * Render input area during streaming (alias for unified method)
505
+ */
506
+ renderStreamingInputArea() {
507
+ this.renderPinnedInputArea();
508
+ }
509
+ /**
510
+ * Render bottom input area - UNIFIED for all modes.
511
+ * Uses cursor save/restore to update bottom without affecting content flow.
512
+ *
513
+ * Layout (same for idle/streaming/ready):
514
+ * - Status bar (streaming timer or "Type a message")
515
+ * - Model info line (provider · model · ctx)
516
+ * - Divider
517
+ * - Input area
518
+ * - Divider
519
+ * - Mode controls
520
+ */
521
+ renderBottomInputArea() {
522
+ const { rows, cols } = this.getSize();
523
+ const maxWidth = Math.max(8, cols - 4);
524
+ const divider = renderDivider(cols - 2);
525
+ const { dim: DIM, reset: R } = UI_COLORS;
526
+ const isStreaming = this.mode === 'streaming';
527
+ // Wrap buffer into display lines
528
+ const { lines, cursorLine, cursorCol } = this.wrapBuffer(maxWidth);
529
+ // Allow multi-line in non-streaming, single line during streaming
530
+ const maxDisplayLines = isStreaming ? 1 : 3;
531
+ const displayLines = Math.min(lines.length, maxDisplayLines);
532
+ const visibleLines = lines.slice(0, displayLines);
533
+ // Calculate total height for bottom area
534
+ const hasModelInfo = !!this.modelInfo;
535
+ const totalHeight = 4 + displayLines + (hasModelInfo ? 1 : 0);
536
+ // Ensure scroll region is always enabled (unified behavior)
537
+ if (!this.scrollRegionActive || this.reservedLines !== totalHeight) {
538
+ this.reservedLines = totalHeight;
217
539
  this.enableScrollRegion();
218
- this.forceRender();
219
540
  }
541
+ const startRow = Math.max(1, rows - totalHeight + 1);
542
+ // Save cursor, hide it
543
+ this.write(ESC.SAVE);
544
+ this.write(ESC.HIDE);
545
+ let currentRow = startRow;
546
+ // Clear the bottom reserved area
547
+ for (let r = startRow; r <= rows; r++) {
548
+ this.write(ESC.TO(r, 1));
549
+ this.write(ESC.CLEAR_LINE);
550
+ }
551
+ // Status bar - UNIFIED: same format for all modes
552
+ this.write(ESC.TO(currentRow, 1));
553
+ this.write(isStreaming ? this.buildStreamingStatusBar(cols) : this.buildStatusBar(cols));
554
+ currentRow++;
555
+ // Model info line (if set)
556
+ if (hasModelInfo) {
557
+ this.write(ESC.TO(currentRow, 1));
558
+ let modelLine = `${DIM}${this.modelInfo}${R}`;
559
+ if (this.contextUsage !== null) {
560
+ const rem = Math.max(0, 100 - this.contextUsage);
561
+ if (rem < 10)
562
+ modelLine += ` ${UI_COLORS.red}⚠ ctx: ${rem}%${R}`;
563
+ else if (rem < 25)
564
+ modelLine += ` ${UI_COLORS.yellow}! ctx: ${rem}%${R}`;
565
+ else
566
+ modelLine += ` ${DIM}· ctx: ${rem}%${R}`;
567
+ }
568
+ this.write(modelLine);
569
+ currentRow++;
570
+ }
571
+ // Top divider
572
+ this.write(ESC.TO(currentRow, 1));
573
+ this.write(divider);
574
+ currentRow++;
575
+ // Input lines with background styling
576
+ for (let i = 0; i < visibleLines.length; i++) {
577
+ this.write(ESC.TO(currentRow, 1));
578
+ const line = visibleLines[i] ?? '';
579
+ const isFirstLine = i === 0;
580
+ this.write(ESC.BG_DARK);
581
+ this.write(ESC.DIM);
582
+ this.write(isFirstLine ? this.config.promptChar : this.config.continuationChar);
583
+ this.write(ESC.RESET);
584
+ this.write(ESC.BG_DARK);
585
+ this.write(line);
586
+ // Pad to edge
587
+ const lineLen = this.config.promptChar.length + line.length;
588
+ const padding = Math.max(0, cols - lineLen - 1);
589
+ if (padding > 0)
590
+ this.write(' '.repeat(padding));
591
+ this.write(ESC.RESET);
592
+ currentRow++;
593
+ }
594
+ // Bottom divider
595
+ this.write(ESC.TO(currentRow, 1));
596
+ this.write(divider);
597
+ currentRow++;
598
+ // Mode controls
599
+ this.write(ESC.TO(currentRow, 1));
600
+ this.write(this.buildModeControls(cols));
601
+ // Cursor positioning depends on mode:
602
+ // - Streaming: restore to content area (where streaming output continues)
603
+ // - Normal: position in input area for typing
604
+ if (isStreaming) {
605
+ this.write(ESC.RESTORE);
606
+ }
607
+ else {
608
+ // Position cursor in input area
609
+ // Input line is at: startRow + (hasModelInfo ? 2 : 1) + cursorLine
610
+ const inputStartRow = startRow + (hasModelInfo ? 2 : 1) + 1; // +1 for status bar, +1 for divider
611
+ const targetRow = inputStartRow + Math.min(cursorLine, displayLines - 1);
612
+ const targetCol = this.config.promptChar.length + cursorCol + 1;
613
+ this.write(ESC.TO(targetRow, Math.min(targetCol, cols)));
614
+ }
615
+ this.write(ESC.SHOW);
616
+ // Track last render state
617
+ this.lastRenderContent = this.buffer;
618
+ this.lastRenderCursor = this.cursor;
619
+ }
620
+ /**
621
+ * Enable or disable flow mode.
622
+ * In flow mode, the input renders immediately after content (wherever cursor is).
623
+ * When disabled, input renders at the absolute bottom of terminal.
624
+ */
625
+ setFlowMode(enabled) {
626
+ if (this.flowMode === enabled)
627
+ return;
628
+ this.flowMode = enabled;
629
+ this.renderDirty = true;
630
+ this.scheduleRender();
631
+ }
632
+ /**
633
+ * Check if flow mode is enabled.
634
+ */
635
+ isFlowMode() {
636
+ return this.flowMode;
637
+ }
638
+ /**
639
+ * Set the row where content ends (for idle mode positioning).
640
+ * Input area will render starting from this row + 1.
641
+ */
642
+ setContentEndRow(row) {
643
+ this.contentEndRow = Math.max(0, row);
644
+ this.renderDirty = true;
645
+ this.scheduleRender();
646
+ }
647
+ /**
648
+ * Set available slash commands for auto-complete suggestions.
649
+ */
650
+ setCommands(commands) {
651
+ this.commandSuggestions = commands;
652
+ this.updateSuggestions();
653
+ }
654
+ /**
655
+ * Update filtered suggestions based on current input.
656
+ */
657
+ updateSuggestions() {
658
+ const input = this.buffer.trim();
659
+ // Only show suggestions when input starts with "/"
660
+ if (!input.startsWith('/')) {
661
+ this.showSuggestions = false;
662
+ this.filteredSuggestions = [];
663
+ this.selectedSuggestionIndex = 0;
664
+ return;
665
+ }
666
+ const query = input.toLowerCase();
667
+ this.filteredSuggestions = this.commandSuggestions.filter(cmd => cmd.command.toLowerCase().startsWith(query) ||
668
+ cmd.command.toLowerCase().includes(query.slice(1)));
669
+ // Show suggestions if we have matches
670
+ this.showSuggestions = this.filteredSuggestions.length > 0;
671
+ // Keep selection in bounds
672
+ if (this.selectedSuggestionIndex >= this.filteredSuggestions.length) {
673
+ this.selectedSuggestionIndex = Math.max(0, this.filteredSuggestions.length - 1);
674
+ }
675
+ }
676
+ /**
677
+ * Select next suggestion (arrow down / tab).
678
+ */
679
+ selectNextSuggestion() {
680
+ if (!this.showSuggestions || this.filteredSuggestions.length === 0)
681
+ return;
682
+ this.selectedSuggestionIndex = (this.selectedSuggestionIndex + 1) % this.filteredSuggestions.length;
683
+ this.renderDirty = true;
684
+ this.scheduleRender();
685
+ }
686
+ /**
687
+ * Select previous suggestion (arrow up / shift+tab).
688
+ */
689
+ selectPrevSuggestion() {
690
+ if (!this.showSuggestions || this.filteredSuggestions.length === 0)
691
+ return;
692
+ this.selectedSuggestionIndex = this.selectedSuggestionIndex === 0
693
+ ? this.filteredSuggestions.length - 1
694
+ : this.selectedSuggestionIndex - 1;
695
+ this.renderDirty = true;
696
+ this.scheduleRender();
697
+ }
698
+ /**
699
+ * Accept current suggestion and insert into buffer.
700
+ */
701
+ acceptSuggestion() {
702
+ if (!this.showSuggestions || this.filteredSuggestions.length === 0)
703
+ return false;
704
+ const selected = this.filteredSuggestions[this.selectedSuggestionIndex];
705
+ if (!selected)
706
+ return false;
707
+ // Replace buffer with selected command
708
+ this.buffer = selected.command + ' ';
709
+ this.cursor = this.buffer.length;
710
+ this.showSuggestions = false;
711
+ this.renderDirty = true;
712
+ this.scheduleRender();
713
+ return true;
714
+ }
715
+ /**
716
+ * Check if suggestions are visible.
717
+ */
718
+ areSuggestionsVisible() {
719
+ return this.showSuggestions && this.filteredSuggestions.length > 0;
720
+ }
721
+ /**
722
+ * Update token count for metrics display
723
+ */
724
+ setTokensUsed(tokens) {
725
+ this.tokensUsed = tokens;
726
+ }
727
+ /**
728
+ * Toggle thinking/reasoning mode
729
+ */
730
+ toggleThinking() {
731
+ this.thinkingEnabled = !this.thinkingEnabled;
732
+ this.emit('thinkingToggle', this.thinkingEnabled);
733
+ this.scheduleRender();
734
+ }
735
+ /**
736
+ * Get thinking enabled state
737
+ */
738
+ isThinkingEnabled() {
739
+ return this.thinkingEnabled;
220
740
  }
221
741
  /**
222
742
  * Keep the top N rows pinned outside the scroll region (used for the launch banner).
223
743
  */
224
744
  setPinnedHeaderLines(count) {
225
- // No pinned header rows anymore; keep everything in the scroll region.
226
- if (this.pinnedTopRows !== 0) {
227
- this.pinnedTopRows = 0;
745
+ // Set pinned header rows (banner area that scroll region excludes)
746
+ if (this.pinnedTopRows !== count) {
747
+ this.pinnedTopRows = count;
228
748
  if (this.scrollRegionActive) {
229
749
  this.applyScrollRegion();
230
750
  }
231
751
  }
232
752
  }
753
+ /**
754
+ * Anchor prompt rendering near a specific row (inline layout). Pass null to
755
+ * restore the default bottom-aligned layout.
756
+ */
757
+ setInlineAnchor(row) {
758
+ if (row === null || row === undefined) {
759
+ this.inlineAnchorRow = null;
760
+ this.inlineLayout = false;
761
+ this.renderDirty = true;
762
+ this.render();
763
+ return;
764
+ }
765
+ const { rows } = this.getSize();
766
+ const clamped = Math.max(1, Math.min(Math.floor(row), rows));
767
+ this.inlineAnchorRow = clamped;
768
+ this.inlineLayout = true;
769
+ this.renderDirty = true;
770
+ this.render();
771
+ }
772
+ /**
773
+ * Provide a dynamic anchor callback. When set, the prompt will follow the
774
+ * output by re-evaluating the anchor before each render.
775
+ */
776
+ setInlineAnchorProvider(provider) {
777
+ this.anchorProvider = provider;
778
+ if (!provider) {
779
+ this.inlineLayout = false;
780
+ this.inlineAnchorRow = null;
781
+ this.renderDirty = true;
782
+ this.render();
783
+ return;
784
+ }
785
+ this.inlineLayout = true;
786
+ this.renderDirty = true;
787
+ this.render();
788
+ }
233
789
  /**
234
790
  * Get current mode
235
791
  */
@@ -339,37 +895,6 @@ export class TerminalInput extends EventEmitter {
339
895
  this.streamingLabel = next;
340
896
  this.scheduleRender();
341
897
  }
342
- /**
343
- * Surface meta status just above the divider (e.g., elapsed time or token usage).
344
- */
345
- setMetaStatus(meta) {
346
- const nextElapsed = typeof meta.elapsedSeconds === 'number' && Number.isFinite(meta.elapsedSeconds) && meta.elapsedSeconds >= 0
347
- ? Math.floor(meta.elapsedSeconds)
348
- : null;
349
- const nextTokens = typeof meta.tokensUsed === 'number' && Number.isFinite(meta.tokensUsed) && meta.tokensUsed >= 0
350
- ? Math.floor(meta.tokensUsed)
351
- : null;
352
- const nextLimit = typeof meta.tokenLimit === 'number' && Number.isFinite(meta.tokenLimit) && meta.tokenLimit > 0
353
- ? Math.floor(meta.tokenLimit)
354
- : null;
355
- const nextThinking = typeof meta.thinkingMs === 'number' && Number.isFinite(meta.thinkingMs) && meta.thinkingMs >= 0
356
- ? Math.floor(meta.thinkingMs)
357
- : null;
358
- const nextThinkingHasContent = !!meta.thinkingHasContent;
359
- if (this.metaElapsedSeconds === nextElapsed &&
360
- this.metaTokensUsed === nextTokens &&
361
- this.metaTokenLimit === nextLimit &&
362
- this.metaThinkingMs === nextThinking &&
363
- this.metaThinkingHasContent === nextThinkingHasContent) {
364
- return;
365
- }
366
- this.metaElapsedSeconds = nextElapsed;
367
- this.metaTokensUsed = nextTokens;
368
- this.metaTokenLimit = nextLimit;
369
- this.metaThinkingMs = nextThinking;
370
- this.metaThinkingHasContent = nextThinkingHasContent;
371
- this.scheduleRender();
372
- }
373
898
  /**
374
899
  * Keep mode toggles (verification/auto-continue) visible in the control bar.
375
900
  * Hotkey labels remain stable so the bar looks the same before/during streaming.
@@ -379,22 +904,26 @@ export class TerminalInput extends EventEmitter {
379
904
  const nextAutoContinue = !!options.autoContinueEnabled;
380
905
  const nextVerifyHotkey = options.verificationHotkey ?? this.verificationHotkey;
381
906
  const nextAutoHotkey = options.autoContinueHotkey ?? this.autoContinueHotkey;
382
- const nextThinkingHotkey = options.thinkingHotkey ?? this.thinkingHotkey;
383
- const nextThinkingLabel = options.thinkingModeLabel === undefined ? this.thinkingModeLabel : (options.thinkingModeLabel || null);
384
907
  if (this.verificationEnabled === nextVerification &&
385
908
  this.autoContinueEnabled === nextAutoContinue &&
386
909
  this.verificationHotkey === nextVerifyHotkey &&
387
- this.autoContinueHotkey === nextAutoHotkey &&
388
- this.thinkingHotkey === nextThinkingHotkey &&
389
- this.thinkingModeLabel === nextThinkingLabel) {
910
+ this.autoContinueHotkey === nextAutoHotkey) {
390
911
  return;
391
912
  }
392
913
  this.verificationEnabled = nextVerification;
393
914
  this.autoContinueEnabled = nextAutoContinue;
394
915
  this.verificationHotkey = nextVerifyHotkey;
395
916
  this.autoContinueHotkey = nextAutoHotkey;
396
- this.thinkingHotkey = nextThinkingHotkey;
397
- this.thinkingModeLabel = nextThinkingLabel;
917
+ this.scheduleRender();
918
+ }
919
+ /**
920
+ * Set the model info string (e.g., "OpenAI · gpt-4")
921
+ * This is displayed persistently above the input area.
922
+ */
923
+ setModelInfo(info) {
924
+ if (this.modelInfo === info)
925
+ return;
926
+ this.modelInfo = info;
398
927
  this.scheduleRender();
399
928
  }
400
929
  /**
@@ -407,390 +936,298 @@ export class TerminalInput extends EventEmitter {
407
936
  this.scheduleRender();
408
937
  }
409
938
  /**
410
- * Surface model/provider context in the controls bar.
411
- */
412
- setModelContext(options) {
413
- const nextModel = options.model?.trim() || null;
414
- const nextProvider = options.provider?.trim() || null;
415
- if (this.modelLabel === nextModel && this.providerLabel === nextProvider) {
416
- return;
417
- }
418
- this.modelLabel = nextModel;
419
- this.providerLabel = nextProvider;
420
- this.scheduleRender();
421
- }
422
- /**
423
- * Render the input area - Claude Code style with mode controls
939
+ * Render the input area - UNIFIED for all modes
424
940
  *
425
- * During streaming we keep the scroll region active and repaint only the
426
- * pinned status/input block (throttled) so streamed content can scroll
427
- * naturally above while elapsed time and status stay fresh.
941
+ * Uses the same bottom-pinned layout with scroll regions for:
942
+ * - Idle mode: Shows "Type a message" hint
943
+ * - Streaming mode: Shows "● Streaming Xs" timer
944
+ * - Ready mode: Shows status info
428
945
  */
429
946
  render() {
430
947
  if (!this.canRender())
431
948
  return;
432
949
  if (this.isRendering)
433
950
  return;
434
- const streamingActive = this.mode === 'streaming' || isStreamingMode();
435
- // During streaming we still render the pinned input/status region, but throttle
436
- // to avoid fighting with the streamed content flow.
437
- if (streamingActive && this.lastStreamingRender > 0) {
438
- const elapsed = Date.now() - this.lastStreamingRender;
439
- const waitMs = Math.max(0, this.streamingRenderInterval - elapsed);
440
- if (waitMs > 0) {
441
- this.renderDirty = true;
442
- this.scheduleStreamingRender(waitMs);
443
- return;
444
- }
445
- }
446
951
  const shouldSkip = !this.renderDirty &&
447
952
  this.buffer === this.lastRenderContent &&
448
953
  this.cursor === this.lastRenderCursor;
449
954
  this.renderDirty = false;
450
- // Skip if nothing changed and no explicit refresh requested
955
+ // Skip if nothing changed (unless explicitly forced)
451
956
  if (shouldSkip) {
452
957
  return;
453
958
  }
454
- // If write lock is held, defer render to avoid race conditions
959
+ // If write lock is held, defer render
455
960
  if (writeLock.isLocked()) {
456
961
  writeLock.safeWrite(() => this.render());
457
962
  return;
458
963
  }
459
- const performRender = () => {
460
- if (!this.scrollRegionActive) {
461
- this.enableScrollRegion();
462
- }
463
- const { rows, cols } = this.getSize();
464
- const maxWidth = Math.max(8, cols - 4);
465
- // Wrap buffer into display lines
466
- const { lines, cursorLine, cursorCol } = this.wrapBuffer(maxWidth);
467
- const availableForContent = Math.max(1, rows - 3); // room for separator + input + controls
468
- const maxVisible = Math.max(1, Math.min(this.config.maxLines, availableForContent));
469
- const displayLines = Math.min(lines.length, maxVisible);
470
- const metaLines = this.buildMetaLines(cols - 2);
471
- // Reserved lines: optional meta lines + separator(1) + input lines + controls(1)
472
- this.updateReservedLines(displayLines + 2 + metaLines.length);
473
- // Calculate display window (keep cursor visible)
474
- let startLine = 0;
475
- if (lines.length > displayLines) {
476
- startLine = Math.max(0, cursorLine - displayLines + 1);
477
- startLine = Math.min(startLine, lines.length - displayLines);
478
- }
479
- const visibleLines = lines.slice(startLine, startLine + displayLines);
480
- const adjustedCursorLine = cursorLine - startLine;
481
- // Hide cursor during render to prevent flicker
482
- this.write(ESC.HIDE);
483
- this.write(ESC.RESET);
484
- const startRow = Math.max(1, rows - this.reservedLines + 1);
485
- let currentRow = startRow;
486
- // Clear the reserved block to avoid stale meta/status lines
487
- this.clearReservedArea(startRow, this.reservedLines, cols);
488
- // Meta/status header (elapsed, tokens/context)
489
- for (const metaLine of metaLines) {
490
- this.write(ESC.TO(currentRow, 1));
491
- this.write(ESC.CLEAR_LINE);
492
- this.write(metaLine);
493
- currentRow += 1;
494
- }
495
- // Separator line
964
+ this.isRendering = true;
965
+ writeLock.lock('terminalInput.render');
966
+ try {
967
+ // UNIFIED: Use the same bottom input area for all modes
968
+ this.renderBottomInputArea();
969
+ }
970
+ finally {
971
+ writeLock.unlock();
972
+ this.isRendering = false;
973
+ }
974
+ }
975
+ /**
976
+ * Render in flow mode - delegates to bottom-pinned for stability.
977
+ *
978
+ * Flow mode attempted inline rendering but caused duplicate renders
979
+ * due to unreliable cursor position tracking. Bottom-pinned is reliable.
980
+ */
981
+ renderFlowMode() {
982
+ // Use stable bottom-pinned approach
983
+ this.renderBottomPinned();
984
+ }
985
+ /**
986
+ * Render in bottom-pinned mode - Claude Code style with suggestions
987
+ *
988
+ * Works for both normal and streaming modes:
989
+ * - During streaming: saves/restores cursor position
990
+ * - Status bar shows streaming info or "Type a message"
991
+ *
992
+ * Layout when suggestions visible:
993
+ * - Top divider
994
+ * - Input line(s)
995
+ * - Bottom divider
996
+ * - Suggestions (command list)
997
+ *
998
+ * Layout when suggestions hidden:
999
+ * - Status bar (Ready/Streaming)
1000
+ * - Top divider
1001
+ * - Input line(s)
1002
+ * - Bottom divider
1003
+ * - Mode controls
1004
+ */
1005
+ renderBottomPinned() {
1006
+ const { rows, cols } = this.getSize();
1007
+ const maxWidth = Math.max(8, cols - 4);
1008
+ const isStreaming = this.mode === 'streaming';
1009
+ // Use unified pinned input area (works for both streaming and normal)
1010
+ // Only use complex rendering when suggestions are visible
1011
+ const hasSuggestions = !isStreaming && this.showSuggestions && this.filteredSuggestions.length > 0;
1012
+ if (!hasSuggestions) {
1013
+ this.renderPinnedInputArea();
1014
+ return;
1015
+ }
1016
+ // Wrap buffer into display lines
1017
+ const { lines, cursorLine, cursorCol } = this.wrapBuffer(maxWidth);
1018
+ const availableForContent = Math.max(1, rows - 3);
1019
+ const maxVisible = Math.max(1, Math.min(this.config.maxLines, availableForContent));
1020
+ const displayLines = Math.min(lines.length, maxVisible);
1021
+ // Calculate display window (keep cursor visible)
1022
+ let startLine = 0;
1023
+ if (lines.length > displayLines) {
1024
+ startLine = Math.max(0, cursorLine - displayLines + 1);
1025
+ startLine = Math.min(startLine, lines.length - displayLines);
1026
+ }
1027
+ const visibleLines = lines.slice(startLine, startLine + displayLines);
1028
+ const adjustedCursorLine = cursorLine - startLine;
1029
+ // Calculate suggestion display (not during streaming)
1030
+ const suggestionsToShow = (!isStreaming && this.showSuggestions)
1031
+ ? this.filteredSuggestions.slice(0, this.maxVisibleSuggestions)
1032
+ : [];
1033
+ const suggestionLines = suggestionsToShow.length;
1034
+ this.write(ESC.HIDE);
1035
+ this.write(ESC.RESET);
1036
+ const divider = renderDivider(cols - 2);
1037
+ // Calculate positions from absolute bottom
1038
+ let currentRow;
1039
+ if (suggestionLines > 0) {
1040
+ // With suggestions: input area + dividers + suggestions
1041
+ // Layout: [topDiv] [input] [bottomDiv] [suggestions...]
1042
+ const totalHeight = 2 + visibleLines.length + suggestionLines; // 2 dividers
1043
+ currentRow = Math.max(1, rows - totalHeight + 1);
1044
+ this.updateReservedLines(totalHeight);
1045
+ // Clear from current position to end of screen to remove any "ghost" content
1046
+ this.write(ESC.TO(currentRow, 1));
1047
+ this.write(ESC.CLEAR_TO_END);
1048
+ // Top divider
496
1049
  this.write(ESC.TO(currentRow, 1));
497
- this.write(ESC.CLEAR_LINE);
498
- const divider = renderDivider(cols - 2);
499
1050
  this.write(divider);
500
- currentRow += 1;
501
- // Render input lines
1051
+ currentRow++;
1052
+ // Input lines
502
1053
  let finalRow = currentRow;
503
1054
  let finalCol = 3;
504
1055
  for (let i = 0; i < visibleLines.length; i++) {
505
- const rowNum = currentRow + i;
506
- this.write(ESC.TO(rowNum, 1));
507
- this.write(ESC.CLEAR_LINE);
1056
+ this.write(ESC.TO(currentRow, 1));
508
1057
  const line = visibleLines[i] ?? '';
509
1058
  const absoluteLineIdx = startLine + i;
510
1059
  const isFirstLine = absoluteLineIdx === 0;
511
1060
  const isCursorLine = i === adjustedCursorLine;
512
- // Background
513
- this.write(ESC.BG_DARK);
514
- // Prompt prefix
515
- this.write(ESC.DIM);
516
1061
  this.write(isFirstLine ? this.config.promptChar : this.config.continuationChar);
517
- this.write(ESC.RESET);
518
- this.write(ESC.BG_DARK);
519
1062
  if (isCursorLine) {
520
- // Render with block cursor
521
1063
  const col = Math.min(cursorCol, line.length);
522
- const before = line.slice(0, col);
523
- const at = col < line.length ? line[col] : ' ';
524
- const after = col < line.length ? line.slice(col + 1) : '';
525
- this.write(before);
526
- this.write(ESC.REVERSE + ESC.BOLD);
527
- this.write(at);
528
- this.write(ESC.RESET + ESC.BG_DARK);
529
- this.write(after);
530
- finalRow = rowNum;
1064
+ this.write(line.slice(0, col));
1065
+ this.write(ESC.REVERSE);
1066
+ this.write(col < line.length ? line[col] : ' ');
1067
+ this.write(ESC.RESET);
1068
+ this.write(line.slice(col + 1));
1069
+ finalRow = currentRow;
531
1070
  finalCol = this.config.promptChar.length + col + 1;
532
1071
  }
533
1072
  else {
534
1073
  this.write(line);
535
1074
  }
536
- // Pad to edge for clean look
537
- const lineLen = this.config.promptChar.length + line.length + (isCursorLine && cursorCol >= line.length ? 1 : 0);
538
- const padding = Math.max(0, cols - lineLen - 1);
539
- if (padding > 0)
540
- this.write(' '.repeat(padding));
541
- this.write(ESC.RESET);
1075
+ currentRow++;
542
1076
  }
543
- // Mode controls line (Claude Code style)
544
- const controlRow = currentRow + visibleLines.length;
545
- this.write(ESC.TO(controlRow, 1));
546
- this.write(ESC.CLEAR_LINE);
547
- this.write(this.buildModeControls(cols));
548
- // Position cursor in the input box for user editing
549
- this.write(ESC.TO(finalRow, Math.min(finalCol, cols)));
550
- this.write(ESC.SHOW);
551
- // Update state
552
- this.lastRenderContent = this.buffer;
553
- this.lastRenderCursor = this.cursor;
554
- this.lastStreamingRender = streamingActive ? Date.now() : 0;
555
- if (this.streamingRenderTimer) {
556
- clearTimeout(this.streamingRenderTimer);
557
- this.streamingRenderTimer = null;
1077
+ // Bottom divider
1078
+ this.write(ESC.TO(currentRow, 1));
1079
+ this.write(divider);
1080
+ currentRow++;
1081
+ // Suggestions (Claude Code style)
1082
+ for (let i = 0; i < suggestionsToShow.length; i++) {
1083
+ this.write(ESC.TO(currentRow, 1));
1084
+ const suggestion = suggestionsToShow[i];
1085
+ const isSelected = i === this.selectedSuggestionIndex;
1086
+ // Indent and highlight selected
1087
+ this.write(' ');
1088
+ if (isSelected) {
1089
+ this.write(ESC.REVERSE);
1090
+ this.write(ESC.BOLD);
1091
+ }
1092
+ this.write(suggestion.command);
1093
+ if (isSelected) {
1094
+ this.write(ESC.RESET);
1095
+ }
1096
+ // Description (dimmed)
1097
+ const descSpace = cols - suggestion.command.length - 8;
1098
+ if (descSpace > 10 && suggestion.description) {
1099
+ const desc = suggestion.description.slice(0, descSpace);
1100
+ this.write(ESC.RESET);
1101
+ this.write(ESC.DIM);
1102
+ this.write(' ');
1103
+ this.write(desc);
1104
+ this.write(ESC.RESET);
1105
+ }
1106
+ currentRow++;
558
1107
  }
559
- };
560
- // Use write lock during render to prevent interleaved output
561
- writeLock.lock('terminalInput.render');
562
- this.isRendering = true;
563
- try {
564
- performRender();
565
- }
566
- finally {
567
- writeLock.unlock();
568
- this.isRendering = false;
1108
+ // Position cursor in input area
1109
+ this.write(ESC.TO(finalRow, Math.min(finalCol, cols)));
569
1110
  }
1111
+ this.write(ESC.SHOW);
1112
+ // Update state
1113
+ this.lastRenderContent = this.buffer;
1114
+ this.lastRenderCursor = this.cursor;
570
1115
  }
571
1116
  /**
572
- * Build one or more compact meta lines above the divider (thinking, status, usage).
573
- * During streaming, shows model line pinned above streaming info.
1117
+ * Build status bar for streaming mode (shows elapsed time, queue count).
574
1118
  */
575
- buildMetaLines(width) {
576
- const streamingActive = this.mode === 'streaming' || isStreamingMode();
577
- const lines = [];
578
- // Model line should ALWAYS be shown (pinned above streaming content)
579
- if (this.modelLabel) {
580
- const modelText = this.providerLabel
581
- ? `model ${this.modelLabel} @ ${this.providerLabel}`
582
- : `model ${this.modelLabel}`;
583
- lines.push(renderStatusLine([{ text: modelText, tone: 'info' }], width));
584
- }
585
- // During streaming, add a compact status line with essential info
586
- if (streamingActive) {
587
- const parts = [];
588
- // Essential streaming info
589
- if (this.metaThinkingMs !== null) {
590
- parts.push({ text: formatThinking(this.metaThinkingMs, this.metaThinkingHasContent), tone: 'info' });
591
- }
592
- if (this.metaElapsedSeconds !== null) {
593
- parts.push({ text: this.formatElapsedLabel(this.metaElapsedSeconds), tone: 'muted' });
594
- }
595
- parts.push({ text: 'esc to stop', tone: 'warn' });
596
- if (parts.length) {
597
- lines.push(renderStatusLine(parts, width));
598
- }
599
- return lines;
600
- }
601
- // Non-streaming: show full status info (model line already added above)
602
- if (this.metaThinkingMs !== null) {
603
- const thinkingText = formatThinking(this.metaThinkingMs, this.metaThinkingHasContent);
604
- lines.push(renderStatusLine([{ text: thinkingText, tone: 'info' }], width));
605
- }
606
- const statusParts = [];
607
- const statusLabel = this.statusMessage ?? this.streamingLabel;
608
- if (statusLabel) {
609
- statusParts.push({ text: statusLabel, tone: 'info' });
610
- }
611
- if (this.metaElapsedSeconds !== null) {
612
- statusParts.push({ text: this.formatElapsedLabel(this.metaElapsedSeconds), tone: 'muted' });
613
- }
614
- const tokensRemaining = this.computeTokensRemaining();
615
- if (tokensRemaining !== null) {
616
- statusParts.push({ text: `↓ ${tokensRemaining}`, tone: 'muted' });
617
- }
618
- if (statusParts.length) {
619
- lines.push(renderStatusLine(statusParts, width));
620
- }
621
- const usageParts = [];
622
- if (this.metaTokensUsed !== null) {
623
- const formattedUsed = this.formatTokenCount(this.metaTokensUsed);
624
- const formattedLimit = this.metaTokenLimit ? `/${this.formatTokenCount(this.metaTokenLimit)}` : '';
625
- usageParts.push({ text: `tokens ${formattedUsed}${formattedLimit}`, tone: 'muted' });
626
- }
627
- if (this.contextUsage !== null) {
628
- const tone = this.contextUsage >= 75 ? 'warn' : 'muted';
629
- const left = Math.max(0, 100 - this.contextUsage);
630
- usageParts.push({ text: `context used ${this.contextUsage}% (${left}% left)`, tone });
631
- }
1119
+ buildStreamingStatusBar(cols) {
1120
+ const { green: GREEN, cyan: CYAN, dim: DIM, reset: R } = UI_COLORS;
1121
+ // Streaming status with elapsed time
1122
+ let elapsed = '0s';
1123
+ if (this.streamingStartTime) {
1124
+ const secs = Math.floor((Date.now() - this.streamingStartTime) / 1000);
1125
+ const mins = Math.floor(secs / 60);
1126
+ elapsed = mins > 0 ? `${mins}m ${secs % 60}s` : `${secs}s`;
1127
+ }
1128
+ let status = `${GREEN}● Streaming${R} ${elapsed}`;
1129
+ // Queue indicator
632
1130
  if (this.queue.length > 0) {
633
- usageParts.push({ text: `queued ${this.queue.length}`, tone: 'info' });
634
- }
635
- if (usageParts.length) {
636
- lines.push(renderStatusLine(usageParts, width));
637
- }
638
- return lines;
639
- }
640
- /**
641
- * Clear the reserved bottom block (meta + divider + input + controls) before repainting.
642
- */
643
- clearReservedArea(startRow, reservedLines, cols) {
644
- const width = Math.max(1, cols);
645
- for (let i = 0; i < reservedLines; i++) {
646
- const row = startRow + i;
647
- this.write(ESC.TO(row, 1));
648
- this.write(' '.repeat(width));
1131
+ status += ` ${DIM}·${R} ${CYAN}${this.queue.length} queued${R}`;
649
1132
  }
1133
+ // Hint for typing
1134
+ status += ` ${DIM}· type to queue message${R}`;
1135
+ return status;
650
1136
  }
651
1137
  /**
652
- * Build Claude Code style mode controls line.
653
- * Combines streaming label + override status + main status for simultaneous display.
1138
+ * Build status bar showing streaming/ready status and key info.
1139
+ * This is the TOP line above the input area - minimal Claude Code style.
654
1140
  */
655
- buildModeControls(cols) {
656
- const width = Math.max(8, cols - 2);
657
- const leftParts = [];
658
- const rightParts = [];
659
- if (this.streamingLabel) {
660
- leftParts.push({ text: `◐ ${this.streamingLabel}`, tone: 'info' });
1141
+ buildStatusBar(cols) {
1142
+ const maxWidth = cols - 2;
1143
+ const parts = [];
1144
+ // Streaming status with elapsed time (left side)
1145
+ if (this.mode === 'streaming') {
1146
+ let statusText = '● Streaming';
1147
+ if (this.streamingStartTime) {
1148
+ const elapsed = Math.floor((Date.now() - this.streamingStartTime) / 1000);
1149
+ const mins = Math.floor(elapsed / 60);
1150
+ const secs = elapsed % 60;
1151
+ statusText += mins > 0 ? ` ${mins}m ${secs}s` : ` ${secs}s`;
1152
+ }
1153
+ parts.push(`\x1b[32m${statusText}\x1b[0m`); // Green
661
1154
  }
662
- if (this.overrideStatusMessage) {
663
- leftParts.push({ text: `⚠ ${this.overrideStatusMessage}`, tone: 'warn' });
664
- }
665
- if (this.statusMessage) {
666
- leftParts.push({ text: this.statusMessage, tone: this.streamingLabel ? 'muted' : 'info' });
667
- }
668
- const editHotkey = this.formatHotkey('shift+tab');
669
- const editLabel = this.editMode === 'display-edits' ? 'edits: accept' : 'edits: ask';
670
- const editTone = this.editMode === 'display-edits' ? 'success' : 'muted';
671
- leftParts.push({ text: `${editHotkey} ${editLabel}`, tone: editTone });
672
- const verifyHotkey = this.formatHotkey(this.verificationHotkey);
673
- const verifyLabel = this.verificationEnabled ? 'verify:on' : 'verify:off';
674
- leftParts.push({ text: `${verifyHotkey} ${verifyLabel}`, tone: this.verificationEnabled ? 'success' : 'muted' });
675
- const continueHotkey = this.formatHotkey(this.autoContinueHotkey);
676
- const continueLabel = this.autoContinueEnabled ? 'auto:on' : 'auto:off';
677
- leftParts.push({ text: `${continueHotkey} ${continueLabel}`, tone: this.autoContinueEnabled ? 'info' : 'muted' });
678
- if (this.queue.length > 0 && this.mode !== 'streaming') {
679
- leftParts.push({ text: `queued ${this.queue.length}`, tone: 'info' });
680
- }
681
- if (this.buffer.includes('\n')) {
682
- const lineCount = this.buffer.split('\n').length;
683
- leftParts.push({ text: `${lineCount} lines`, tone: 'muted' });
1155
+ // Queue indicator during streaming
1156
+ if (this.mode === 'streaming' && this.queue.length > 0) {
1157
+ parts.push(`\x1b[36mqueued: ${this.queue.length}\x1b[0m`); // Cyan
684
1158
  }
1159
+ // Paste indicator
685
1160
  if (this.pastePlaceholders.length > 0) {
686
- const latest = this.pastePlaceholders[this.pastePlaceholders.length - 1];
687
- leftParts.push({
688
- text: `paste #${latest.id} +${latest.lineCount} lines (⌫ to drop)`,
689
- tone: 'info',
690
- });
1161
+ const totalLines = this.pastePlaceholders.reduce((sum, p) => sum + p.lineCount, 0);
1162
+ parts.push(`\x1b[36m📋 ${totalLines}L\x1b[0m`);
691
1163
  }
692
- const contextRemaining = this.computeContextRemaining();
693
- if (this.thinkingModeLabel) {
694
- const thinkingHotkey = this.formatHotkey(this.thinkingHotkey);
695
- rightParts.push({ text: `${thinkingHotkey} thinking:${this.thinkingModeLabel}`, tone: 'info' });
696
- }
697
- // Show model in controls only when NOT streaming (during streaming it's in meta lines)
698
- const streamingActive = this.mode === 'streaming' || isStreamingMode();
699
- if (this.modelLabel && !streamingActive) {
700
- const modelText = this.providerLabel ? `${this.modelLabel} @ ${this.providerLabel}` : this.modelLabel;
701
- rightParts.push({ text: modelText, tone: 'muted' });
702
- }
703
- if (contextRemaining !== null) {
704
- const tone = contextRemaining <= 10 ? 'warn' : 'muted';
705
- const label = contextRemaining === 0 && this.contextUsage !== null
706
- ? 'Context auto-compact imminent'
707
- : `Context left until auto-compact: ${contextRemaining}%`;
708
- rightParts.push({ text: label, tone });
709
- }
710
- if (!rightParts.length || width < 60) {
711
- const merged = rightParts.length ? [...leftParts, ...rightParts] : leftParts;
712
- return renderStatusLine(merged, width);
713
- }
714
- const leftWidth = Math.max(12, Math.floor(width * 0.6));
715
- const rightWidth = Math.max(14, width - leftWidth - 1);
716
- const leftText = renderStatusLine(leftParts, leftWidth);
717
- const rightText = renderStatusLine(rightParts, rightWidth);
718
- const spacing = Math.max(1, width - this.visibleLength(leftText) - this.visibleLength(rightText));
719
- return `${leftText}${' '.repeat(spacing)}${rightText}`;
720
- }
721
- formatHotkey(hotkey) {
722
- const normalized = hotkey.trim().toLowerCase();
723
- if (!normalized)
724
- return hotkey;
725
- const parts = normalized.split('+').filter(Boolean);
726
- const map = {
727
- shift: '⇧',
728
- sh: '⇧',
729
- alt: '⌥',
730
- option: '⌥',
731
- opt: '⌥',
732
- ctrl: '⌃',
733
- control: '⌃',
734
- cmd: '⌘',
735
- meta: '⌘',
736
- };
737
- const formatted = parts
738
- .map((part) => {
739
- const symbol = map[part];
740
- if (symbol)
741
- return symbol;
742
- return part.length === 1 ? part.toUpperCase() : part.toUpperCase();
743
- })
744
- .join('');
745
- return formatted || hotkey;
746
- }
747
- computeContextRemaining() {
748
- if (this.contextUsage === null) {
749
- return null;
750
- }
751
- return Math.max(0, this.contextAutoCompactThreshold - this.contextUsage);
752
- }
753
- computeTokensRemaining() {
754
- if (this.metaTokensUsed === null || this.metaTokenLimit === null) {
755
- return null;
756
- }
757
- const remaining = Math.max(0, this.metaTokenLimit - this.metaTokensUsed);
758
- return this.formatTokenCount(remaining);
759
- }
760
- formatElapsedLabel(seconds) {
761
- if (seconds < 60) {
762
- return `${seconds}s`;
763
- }
764
- const mins = Math.floor(seconds / 60);
765
- const secs = seconds % 60;
766
- return secs > 0 ? `${mins}m ${secs}s` : `${mins}m`;
767
- }
768
- formatTokenCount(value) {
769
- if (!Number.isFinite(value)) {
770
- return `${value}`;
1164
+ // Override/warning status
1165
+ if (this.overrideStatusMessage) {
1166
+ parts.push(`\x1b[33m⚠ ${this.overrideStatusMessage}\x1b[0m`); // Yellow
1167
+ }
1168
+ // If idle with empty buffer, show quick shortcuts
1169
+ if (this.mode !== 'streaming' && this.buffer.length === 0 && parts.length === 0) {
1170
+ return `${ESC.DIM}Type a message or / for commands${ESC.RESET}`;
771
1171
  }
772
- if (value >= 1_000_000) {
773
- return `${(value / 1_000_000).toFixed(1)}M`;
1172
+ // Multi-line indicator
1173
+ if (this.buffer.includes('\n')) {
1174
+ parts.push(`${ESC.DIM}${this.buffer.split('\n').length}L${ESC.RESET}`);
774
1175
  }
775
- if (value >= 1_000) {
776
- return `${(value / 1_000).toFixed(1)}k`;
1176
+ if (parts.length === 0) {
1177
+ return ''; // Empty status bar when idle
777
1178
  }
778
- return `${Math.round(value)}`;
779
- }
780
- visibleLength(value) {
781
- const ansiPattern = /\u001B\[[0-?]*[ -/]*[@-~]/g;
782
- return value.replace(ansiPattern, '').length;
1179
+ const joined = parts.join(`${ESC.DIM} · ${ESC.RESET}`);
1180
+ return joined.slice(0, maxWidth);
783
1181
  }
784
1182
  /**
785
- * Debug-only snapshot used by tests to assert rendered strings without
786
- * needing a TTY. Not used by production code.
1183
+ * Build mode controls line showing toggles and context info.
1184
+ * This is the BOTTOM line below the input area - Claude Code style layout with erosolar features.
1185
+ *
1186
+ * Layout: [toggles on left] ... [context info on right]
787
1187
  */
788
- getDebugUiSnapshot(width) {
789
- const cols = Math.max(8, width ?? this.getSize().cols);
790
- return {
791
- meta: this.buildMetaLines(cols - 2),
792
- controls: this.buildModeControls(cols),
793
- };
1188
+ buildModeControls(cols) {
1189
+ const maxWidth = cols - 2;
1190
+ // Use schema-defined colors for consistency
1191
+ const { green: GREEN, yellow: YELLOW, cyan: CYAN, magenta: MAGENTA, red: RED, dim: DIM, reset: R } = UI_COLORS;
1192
+ // Mode toggles with colors (following ModeControlsSchema)
1193
+ const toggles = [];
1194
+ // Edit mode (green=auto, yellow=ask) - per schema.editMode
1195
+ if (this.editMode === 'display-edits') {
1196
+ toggles.push(`${GREEN}⏵⏵ auto-edit${R}`);
1197
+ }
1198
+ else {
1199
+ toggles.push(`${YELLOW}⏸⏸ ask-first${R}`);
1200
+ }
1201
+ // Thinking mode (cyan when on) - per schema.thinkingMode
1202
+ toggles.push(this.thinkingEnabled ? `${CYAN}💭 think${R}` : `${DIM}○ think${R}`);
1203
+ // Verification (green when on) - per schema.verificationMode
1204
+ toggles.push(this.verificationEnabled ? `${GREEN}✓ verify${R}` : `${DIM}○ verify${R}`);
1205
+ // Auto-continue (magenta when on) - per schema.autoContinueMode
1206
+ toggles.push(this.autoContinueEnabled ? `${MAGENTA}↻ auto${R}` : `${DIM}○ auto${R}`);
1207
+ const leftPart = toggles.join(`${DIM} · ${R}`) + `${DIM} (⇧⇥)${R}`;
1208
+ // Context usage with color - per schema.contextUsage thresholds
1209
+ let rightPart = '';
1210
+ if (this.contextUsage !== null) {
1211
+ const rem = Math.max(0, 100 - this.contextUsage);
1212
+ // Thresholds: critical < 10%, warning < 25%
1213
+ if (rem < 10)
1214
+ rightPart = `${RED}⚠ ctx: ${rem}%${R}`;
1215
+ else if (rem < 25)
1216
+ rightPart = `${YELLOW}! ctx: ${rem}%${R}`;
1217
+ else
1218
+ rightPart = `${DIM}ctx: ${rem}%${R}`;
1219
+ }
1220
+ // Calculate visible lengths (strip ANSI)
1221
+ const strip = (s) => s.replace(/\x1b\[[0-9;]*m/g, '');
1222
+ const leftLen = strip(leftPart).length;
1223
+ const rightLen = strip(rightPart).length;
1224
+ if (leftLen + rightLen < maxWidth - 4) {
1225
+ return `${leftPart}${' '.repeat(maxWidth - leftLen - rightLen)}${rightPart}`;
1226
+ }
1227
+ if (rightLen > 0 && leftLen + 8 < maxWidth) {
1228
+ return `${leftPart} ${rightPart}`;
1229
+ }
1230
+ return leftPart;
794
1231
  }
795
1232
  /**
796
1233
  * Force a re-render
@@ -813,19 +1250,17 @@ export class TerminalInput extends EventEmitter {
813
1250
  handleResize() {
814
1251
  this.lastRenderContent = '';
815
1252
  this.lastRenderCursor = -1;
816
- this.resetStreamingRenderThrottle();
817
1253
  // Re-clamp pinned header rows to the new terminal height
818
1254
  this.setPinnedHeaderLines(this.pinnedTopRows);
819
- if (this.scrollRegionActive) {
820
- this.disableScrollRegion();
821
- this.enableScrollRegion();
822
- }
823
1255
  this.scheduleRender();
824
1256
  }
825
1257
  /**
826
1258
  * Register with display's output interceptor to position cursor correctly.
827
1259
  * When scroll region is active, output needs to go to the scroll region,
828
1260
  * not the protected bottom area where the input is rendered.
1261
+ *
1262
+ * NOTE: With scroll region properly set, content naturally stays within
1263
+ * the region boundaries - no cursor manipulation needed per-write.
829
1264
  */
830
1265
  registerOutputInterceptor(display) {
831
1266
  if (this.outputInterceptorCleanup) {
@@ -833,65 +1268,25 @@ export class TerminalInput extends EventEmitter {
833
1268
  }
834
1269
  this.outputInterceptorCleanup = display.registerOutputInterceptor({
835
1270
  beforeWrite: () => {
836
- // Position cursor at current content row (starts at top, moves down).
837
- // When contentRow reaches scrollBottom, terminal handles scrolling.
838
- if (this.scrollRegionActive) {
839
- const { rows } = this.getSize();
840
- const scrollBottom = Math.max(this.pinnedTopRows + 1, rows - this.reservedLines);
841
- const targetRow = Math.min(this.contentRow, scrollBottom);
842
- this.write(ESC.SAVE);
843
- this.write(ESC.TO(targetRow, 1));
844
- }
1271
+ // Scroll region handles content containment automatically
1272
+ // No per-write cursor manipulation needed
845
1273
  },
846
1274
  afterWrite: () => {
847
- // Advance content row and restore cursor.
848
- if (this.scrollRegionActive) {
849
- const { rows } = this.getSize();
850
- const scrollBottom = Math.max(this.pinnedTopRows + 1, rows - this.reservedLines);
851
- // Advance row (clamp at scrollBottom, terminal handles scrolling from there)
852
- this.contentRow = Math.min(this.contentRow + 1, scrollBottom);
853
- this.write(ESC.RESTORE);
854
- }
1275
+ // No cursor manipulation needed
855
1276
  },
856
1277
  });
857
1278
  }
858
- /**
859
- * Write content directly into the scroll region (for banner, user prompts, etc.).
860
- * Content starts at top and flows down, then scrolls when bottom is reached.
861
- */
862
- writeToScrollRegion(content) {
863
- if (!content)
864
- return;
865
- // Ensure scroll region is active
866
- if (!this.scrollRegionActive) {
867
- this.enableScrollRegion();
868
- }
869
- const { rows } = this.getSize();
870
- const scrollBottom = Math.max(this.pinnedTopRows + 1, rows - this.reservedLines);
871
- const targetRow = Math.min(this.contentRow, scrollBottom);
872
- // Write at current content position
873
- this.write(ESC.SAVE);
874
- this.write(ESC.TO(targetRow, 1));
875
- this.write(content);
876
- this.write(ESC.RESTORE);
877
- // Advance contentRow by number of lines written
878
- const lineCount = (content.match(/\n/g) || []).length + 1;
879
- this.contentRow = Math.min(this.contentRow + lineCount, scrollBottom);
880
- }
881
- /**
882
- * Reset content position to start of scroll region.
883
- * Does NOT clear the terminal - content starts from current position.
884
- */
885
- resetContentPosition() {
886
- const scrollTop = Math.max(1, this.pinnedTopRows + 1);
887
- this.contentRow = scrollTop;
888
- }
889
1279
  /**
890
1280
  * Dispose and clean up
891
1281
  */
892
1282
  dispose() {
893
1283
  if (this.disposed)
894
1284
  return;
1285
+ // Clean up streaming render timer
1286
+ if (this.streamingRenderTimer) {
1287
+ clearInterval(this.streamingRenderTimer);
1288
+ this.streamingRenderTimer = null;
1289
+ }
895
1290
  // Clean up output interceptor
896
1291
  if (this.outputInterceptorCleanup) {
897
1292
  this.outputInterceptorCleanup();
@@ -899,7 +1294,6 @@ export class TerminalInput extends EventEmitter {
899
1294
  }
900
1295
  this.disposed = true;
901
1296
  this.enabled = false;
902
- this.resetStreamingRenderThrottle();
903
1297
  this.disableScrollRegion();
904
1298
  this.disableBracketedPaste();
905
1299
  this.buffer = '';
@@ -1005,7 +1399,22 @@ export class TerminalInput extends EventEmitter {
1005
1399
  this.toggleEditMode();
1006
1400
  return true;
1007
1401
  }
1008
- this.insertText(' ');
1402
+ // Tab: toggle paste expansion if in placeholder, otherwise toggle thinking
1403
+ if (this.findPlaceholderAt(this.cursor)) {
1404
+ this.togglePasteExpansion();
1405
+ }
1406
+ else {
1407
+ this.toggleThinking();
1408
+ }
1409
+ return true;
1410
+ case 'escape':
1411
+ // Esc: interrupt if streaming, otherwise clear buffer
1412
+ if (this.mode === 'streaming') {
1413
+ this.emit('interrupt');
1414
+ }
1415
+ else if (this.buffer.length > 0) {
1416
+ this.clear();
1417
+ }
1009
1418
  return true;
1010
1419
  }
1011
1420
  return false;
@@ -1023,6 +1432,7 @@ export class TerminalInput extends EventEmitter {
1023
1432
  this.insertPlainText(chunk, insertPos);
1024
1433
  this.cursor = insertPos + chunk.length;
1025
1434
  this.emit('change', this.buffer);
1435
+ this.updateSuggestions();
1026
1436
  this.scheduleRender();
1027
1437
  }
1028
1438
  insertNewline() {
@@ -1047,6 +1457,7 @@ export class TerminalInput extends EventEmitter {
1047
1457
  this.cursor = Math.max(0, this.cursor - 1);
1048
1458
  }
1049
1459
  this.emit('change', this.buffer);
1460
+ this.updateSuggestions();
1050
1461
  this.scheduleRender();
1051
1462
  }
1052
1463
  deleteForward() {
@@ -1296,9 +1707,7 @@ export class TerminalInput extends EventEmitter {
1296
1707
  if (available <= 0)
1297
1708
  return;
1298
1709
  const chunk = clean.slice(0, available);
1299
- const isMultiline = isMultilinePaste(chunk);
1300
- const isShortMultiline = isMultiline && this.shouldInlineMultiline(chunk);
1301
- if (isMultiline && !isShortMultiline) {
1710
+ if (isMultilinePaste(chunk)) {
1302
1711
  this.insertPastePlaceholder(chunk);
1303
1712
  }
1304
1713
  else {
@@ -1318,7 +1727,6 @@ export class TerminalInput extends EventEmitter {
1318
1727
  return;
1319
1728
  this.applyScrollRegion();
1320
1729
  this.scrollRegionActive = true;
1321
- this.forceRender();
1322
1730
  }
1323
1731
  disableScrollRegion() {
1324
1732
  if (!this.scrollRegionActive)
@@ -1469,19 +1877,17 @@ export class TerminalInput extends EventEmitter {
1469
1877
  this.shiftPlaceholders(position, text.length);
1470
1878
  this.buffer = this.buffer.slice(0, position) + text + this.buffer.slice(position);
1471
1879
  }
1472
- shouldInlineMultiline(content) {
1473
- const lines = content.split('\n').length;
1474
- const maxInlineLines = 4;
1475
- const maxInlineChars = 240;
1476
- return lines <= maxInlineLines && content.length <= maxInlineChars;
1477
- }
1478
1880
  findPlaceholderAt(position) {
1479
1881
  return this.pastePlaceholders.find((ph) => position >= ph.start && position < ph.end) ?? null;
1480
1882
  }
1481
- buildPlaceholder(lineCount) {
1883
+ buildPlaceholder(summary) {
1482
1884
  const id = ++this.pasteCounter;
1483
- const plural = lineCount === 1 ? '' : 's';
1484
- const placeholder = `[Pasted text #${id} +${lineCount} line${plural}]`;
1885
+ const lang = summary.language ? ` ${summary.language.toUpperCase()}` : '';
1886
+ // Show first line preview (truncated)
1887
+ const preview = summary.preview.length > 30
1888
+ ? `${summary.preview.slice(0, 30)}...`
1889
+ : summary.preview;
1890
+ const placeholder = `[📋 #${id}${lang} ${summary.lineCount}L] "${preview}"`;
1485
1891
  return { id, placeholder };
1486
1892
  }
1487
1893
  insertPastePlaceholder(content) {
@@ -1489,21 +1895,67 @@ export class TerminalInput extends EventEmitter {
1489
1895
  if (available <= 0)
1490
1896
  return;
1491
1897
  const cleanContent = content.slice(0, available);
1492
- const lineCount = cleanContent.split('\n').length;
1493
- const { id, placeholder } = this.buildPlaceholder(lineCount);
1898
+ const summary = generatePasteSummary(cleanContent);
1899
+ // For short pastes (< 5 lines), show full content instead of placeholder
1900
+ if (summary.lineCount < 5) {
1901
+ const placeholder = this.findPlaceholderAt(this.cursor);
1902
+ const insertPos = placeholder && this.cursor > placeholder.start ? placeholder.end : this.cursor;
1903
+ this.insertPlainText(cleanContent, insertPos);
1904
+ this.cursor = insertPos + cleanContent.length;
1905
+ return;
1906
+ }
1907
+ const { id, placeholder } = this.buildPlaceholder(summary);
1494
1908
  const insertPos = this.cursor;
1495
1909
  this.shiftPlaceholders(insertPos, placeholder.length);
1496
1910
  this.pastePlaceholders.push({
1497
1911
  id,
1498
1912
  content: cleanContent,
1499
- lineCount,
1913
+ lineCount: summary.lineCount,
1500
1914
  placeholder,
1501
1915
  start: insertPos,
1502
1916
  end: insertPos + placeholder.length,
1917
+ summary,
1918
+ expanded: false,
1503
1919
  });
1504
1920
  this.buffer = this.buffer.slice(0, insertPos) + placeholder + this.buffer.slice(insertPos);
1505
1921
  this.cursor = insertPos + placeholder.length;
1506
1922
  }
1923
+ /**
1924
+ * Toggle expansion of a paste placeholder at the current cursor position.
1925
+ * When expanded, shows first 3 and last 2 lines of the content.
1926
+ */
1927
+ togglePasteExpansion() {
1928
+ const placeholder = this.findPlaceholderAt(this.cursor);
1929
+ if (!placeholder)
1930
+ return false;
1931
+ placeholder.expanded = !placeholder.expanded;
1932
+ // Update the placeholder text in buffer
1933
+ const newPlaceholder = placeholder.expanded
1934
+ ? this.buildExpandedPlaceholder(placeholder)
1935
+ : this.buildPlaceholder(placeholder.summary).placeholder;
1936
+ const lengthDiff = newPlaceholder.length - placeholder.placeholder.length;
1937
+ // Update buffer
1938
+ this.buffer =
1939
+ this.buffer.slice(0, placeholder.start) +
1940
+ newPlaceholder +
1941
+ this.buffer.slice(placeholder.end);
1942
+ // Update placeholder tracking
1943
+ placeholder.placeholder = newPlaceholder;
1944
+ placeholder.end = placeholder.start + newPlaceholder.length;
1945
+ // Shift other placeholders
1946
+ this.shiftPlaceholders(placeholder.end, lengthDiff, placeholder.id);
1947
+ this.scheduleRender();
1948
+ return true;
1949
+ }
1950
+ buildExpandedPlaceholder(ph) {
1951
+ const lines = ph.content.split('\n');
1952
+ const firstLines = lines.slice(0, 3).map(l => l.slice(0, 60)).join('\n');
1953
+ const lastLines = lines.length > 5
1954
+ ? '\n...\n' + lines.slice(-2).map(l => l.slice(0, 60)).join('\n')
1955
+ : '';
1956
+ const lang = ph.summary.language ? ` ${ph.summary.language.toUpperCase()}` : '';
1957
+ return `[📋 #${ph.id}${lang} ${ph.lineCount}L ▼]\n${firstLines}${lastLines}\n[/📋 #${ph.id}]`;
1958
+ }
1507
1959
  deletePlaceholder(placeholder) {
1508
1960
  const length = placeholder.end - placeholder.start;
1509
1961
  this.buffer = this.buffer.slice(0, placeholder.start) + this.buffer.slice(placeholder.end);
@@ -1511,11 +1963,7 @@ export class TerminalInput extends EventEmitter {
1511
1963
  this.shiftPlaceholders(placeholder.end, -length, placeholder.id);
1512
1964
  this.cursor = placeholder.start;
1513
1965
  }
1514
- updateContextUsage(value, autoCompactThreshold) {
1515
- if (typeof autoCompactThreshold === 'number' && Number.isFinite(autoCompactThreshold)) {
1516
- const boundedThreshold = Math.max(1, Math.min(100, Math.round(autoCompactThreshold)));
1517
- this.contextAutoCompactThreshold = boundedThreshold;
1518
- }
1966
+ updateContextUsage(value) {
1519
1967
  if (value === null || !Number.isFinite(value)) {
1520
1968
  this.contextUsage = null;
1521
1969
  }
@@ -1542,22 +1990,6 @@ export class TerminalInput extends EventEmitter {
1542
1990
  const next = this.editMode === 'display-edits' ? 'ask-permission' : 'display-edits';
1543
1991
  this.setEditMode(next);
1544
1992
  }
1545
- scheduleStreamingRender(delayMs) {
1546
- if (this.streamingRenderTimer)
1547
- return;
1548
- const wait = Math.max(16, delayMs);
1549
- this.streamingRenderTimer = setTimeout(() => {
1550
- this.streamingRenderTimer = null;
1551
- this.render();
1552
- }, wait);
1553
- }
1554
- resetStreamingRenderThrottle() {
1555
- if (this.streamingRenderTimer) {
1556
- clearTimeout(this.streamingRenderTimer);
1557
- this.streamingRenderTimer = null;
1558
- }
1559
- this.lastStreamingRender = 0;
1560
- }
1561
1993
  scheduleRender() {
1562
1994
  if (!this.canRender())
1563
1995
  return;