@compilr-dev/cli 0.4.0 → 0.5.0

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 (315) hide show
  1. package/README.md +30 -12
  2. package/dist/agent.d.ts +74 -1
  3. package/dist/agent.js +259 -76
  4. package/dist/anchors/index.d.ts +9 -0
  5. package/dist/anchors/index.js +9 -0
  6. package/dist/anchors/project-anchors.d.ts +79 -0
  7. package/dist/anchors/project-anchors.js +202 -0
  8. package/dist/commands/handler-types.d.ts +68 -0
  9. package/dist/commands/handler-types.js +8 -0
  10. package/dist/commands/handlers/agent-commands.d.ts +13 -0
  11. package/dist/commands/handlers/agent-commands.js +305 -0
  12. package/dist/commands/handlers/design-commands.d.ts +15 -0
  13. package/dist/commands/handlers/design-commands.js +334 -0
  14. package/dist/commands/handlers/index.d.ts +20 -0
  15. package/dist/commands/handlers/index.js +43 -0
  16. package/dist/commands/handlers/overlay-commands.d.ts +21 -0
  17. package/dist/commands/handlers/overlay-commands.js +287 -0
  18. package/dist/commands/handlers/project-commands.d.ts +11 -0
  19. package/dist/commands/handlers/project-commands.js +167 -0
  20. package/dist/commands/handlers/simple-commands.d.ts +19 -0
  21. package/dist/commands/handlers/simple-commands.js +144 -0
  22. package/dist/commands/index.d.ts +2 -1
  23. package/dist/commands/registry.d.ts +50 -0
  24. package/dist/commands/registry.js +75 -0
  25. package/dist/commands-v2/handlers/context.d.ts +13 -0
  26. package/dist/commands-v2/handlers/context.js +348 -0
  27. package/dist/commands-v2/handlers/core.d.ts +13 -0
  28. package/dist/commands-v2/handlers/core.js +165 -0
  29. package/dist/commands-v2/handlers/debug.d.ts +11 -0
  30. package/dist/commands-v2/handlers/debug.js +159 -0
  31. package/dist/commands-v2/handlers/index.d.ts +12 -0
  32. package/dist/commands-v2/handlers/index.js +24 -0
  33. package/dist/commands-v2/handlers/project.d.ts +22 -0
  34. package/dist/commands-v2/handlers/project.js +814 -0
  35. package/dist/commands-v2/handlers/settings.d.ts +15 -0
  36. package/dist/commands-v2/handlers/settings.js +235 -0
  37. package/dist/commands-v2/index.d.ts +13 -0
  38. package/dist/commands-v2/index.js +15 -0
  39. package/dist/commands-v2/registry.d.ts +37 -0
  40. package/dist/commands-v2/registry.js +80 -0
  41. package/dist/commands-v2/types.d.ts +75 -0
  42. package/dist/commands-v2/types.js +7 -0
  43. package/dist/commands.js +110 -7
  44. package/dist/index.js +288 -29
  45. package/dist/input-handlers/index.d.ts +7 -0
  46. package/dist/input-handlers/index.js +7 -0
  47. package/dist/input-handlers/memory-handler.d.ts +26 -0
  48. package/dist/input-handlers/memory-handler.js +68 -0
  49. package/dist/repl-helpers.d.ts +63 -0
  50. package/dist/repl-helpers.js +318 -0
  51. package/dist/repl-v2.d.ts +155 -0
  52. package/dist/repl-v2.js +774 -0
  53. package/dist/repl.d.ts +32 -4
  54. package/dist/repl.js +250 -977
  55. package/dist/settings/index.d.ts +23 -0
  56. package/dist/settings/index.js +48 -0
  57. package/dist/settings/paths.d.ts +110 -0
  58. package/dist/settings/paths.js +264 -0
  59. package/dist/templates/compilr-md.js +7 -4
  60. package/dist/templates/index.js +3 -4
  61. package/dist/themes/colors.js +3 -1
  62. package/dist/themes/registry.d.ts +5 -36
  63. package/dist/themes/registry.js +11 -95
  64. package/dist/themes/types.d.ts +3 -38
  65. package/dist/themes/types.js +2 -2
  66. package/dist/tools/anchor-tools.d.ts +31 -0
  67. package/dist/tools/anchor-tools.js +255 -0
  68. package/dist/tools/backlog-wrappers.d.ts +54 -0
  69. package/dist/tools/backlog-wrappers.js +338 -0
  70. package/dist/tools/backlog.js +1 -1
  71. package/dist/tools/db-tools.d.ts +65 -0
  72. package/dist/tools/db-tools.js +19 -0
  73. package/dist/tools/document-db.d.ts +43 -0
  74. package/dist/tools/document-db.js +220 -0
  75. package/dist/tools/project-db.d.ts +102 -0
  76. package/dist/tools/project-db.js +370 -0
  77. package/dist/tools/workitem-db.d.ts +103 -0
  78. package/dist/tools/workitem-db.js +549 -0
  79. package/dist/tools.js +13 -3
  80. package/dist/ui/agents-overlay-v2.d.ts +43 -0
  81. package/dist/ui/agents-overlay-v2.js +809 -0
  82. package/dist/ui/agents-overlay.d.ts +5 -5
  83. package/dist/ui/agents-overlay.js +782 -420
  84. package/dist/ui/anchors-overlay.d.ts +12 -0
  85. package/dist/ui/anchors-overlay.js +775 -0
  86. package/dist/ui/arch-type-overlay.d.ts +1 -6
  87. package/dist/ui/arch-type-overlay.js +175 -203
  88. package/dist/ui/ask-user-overlay-v2.d.ts +26 -0
  89. package/dist/ui/ask-user-overlay-v2.js +555 -0
  90. package/dist/ui/ask-user-overlay.d.ts +2 -2
  91. package/dist/ui/ask-user-overlay.js +443 -535
  92. package/dist/ui/ask-user-simple-overlay-v2.d.ts +25 -0
  93. package/dist/ui/ask-user-simple-overlay-v2.js +215 -0
  94. package/dist/ui/ask-user-simple-overlay.d.ts +2 -2
  95. package/dist/ui/ask-user-simple-overlay.js +182 -209
  96. package/dist/ui/backlog-overlay.d.ts +16 -1
  97. package/dist/ui/backlog-overlay.js +525 -659
  98. package/dist/ui/base/index.d.ts +26 -0
  99. package/dist/ui/base/index.js +33 -0
  100. package/dist/ui/base/inline-overlay-utils.d.ts +217 -0
  101. package/dist/ui/base/inline-overlay-utils.js +320 -0
  102. package/dist/ui/base/inline-overlay.d.ts +159 -0
  103. package/dist/ui/base/inline-overlay.js +257 -0
  104. package/dist/ui/base/key-utils.d.ts +15 -0
  105. package/dist/ui/base/key-utils.js +30 -0
  106. package/dist/ui/base/overlay-base-v2.d.ts +193 -0
  107. package/dist/ui/base/overlay-base-v2.js +246 -0
  108. package/dist/ui/base/overlay-base.d.ts +156 -0
  109. package/dist/ui/base/overlay-base.js +238 -0
  110. package/dist/ui/base/overlay-lifecycle.d.ts +65 -0
  111. package/dist/ui/base/overlay-lifecycle.js +159 -0
  112. package/dist/ui/base/overlay-types.d.ts +185 -0
  113. package/dist/ui/base/overlay-types.js +7 -0
  114. package/dist/ui/base/render-utils.d.ts +8 -0
  115. package/dist/ui/base/render-utils.js +11 -0
  116. package/dist/ui/base/screen-stack.d.ts +148 -0
  117. package/dist/ui/base/screen-stack.js +184 -0
  118. package/dist/ui/base/tabbed-list-overlay-v2.d.ts +103 -0
  119. package/dist/ui/base/tabbed-list-overlay-v2.js +317 -0
  120. package/dist/ui/base/tabbed-list-overlay.d.ts +153 -0
  121. package/dist/ui/base/tabbed-list-overlay.js +369 -0
  122. package/dist/ui/commands-overlay-v2.d.ts +33 -0
  123. package/dist/ui/commands-overlay-v2.js +441 -0
  124. package/dist/ui/commands-overlay.d.ts +7 -2
  125. package/dist/ui/commands-overlay.js +384 -355
  126. package/dist/ui/config-overlay.d.ts +5 -4
  127. package/dist/ui/config-overlay.js +243 -513
  128. package/dist/ui/conversation.d.ts +75 -4
  129. package/dist/ui/conversation.js +374 -161
  130. package/dist/ui/docs-overlay.d.ts +17 -0
  131. package/dist/ui/docs-overlay.js +303 -0
  132. package/dist/ui/ephemeral.d.ts +1 -1
  133. package/dist/ui/ephemeral.js +1 -1
  134. package/dist/ui/features/index.d.ts +34 -0
  135. package/dist/ui/features/index.js +34 -0
  136. package/dist/ui/features/input-feature.d.ts +85 -0
  137. package/dist/ui/features/input-feature.js +238 -0
  138. package/dist/ui/features/list-feature.d.ts +155 -0
  139. package/dist/ui/features/list-feature.js +244 -0
  140. package/dist/ui/features/pagination-feature.d.ts +154 -0
  141. package/dist/ui/features/pagination-feature.js +238 -0
  142. package/dist/ui/features/search-feature.d.ts +148 -0
  143. package/dist/ui/features/search-feature.js +185 -0
  144. package/dist/ui/features/tab-feature.d.ts +194 -0
  145. package/dist/ui/features/tab-feature.js +307 -0
  146. package/dist/ui/footer-v2.d.ts +222 -0
  147. package/dist/ui/footer-v2.js +1349 -0
  148. package/dist/ui/footer.d.ts +107 -0
  149. package/dist/ui/footer.js +359 -67
  150. package/dist/ui/guardrail-overlay.d.ts +29 -0
  151. package/dist/ui/guardrail-overlay.js +145 -0
  152. package/dist/ui/help-overlay-v2.d.ts +34 -0
  153. package/dist/ui/help-overlay-v2.js +309 -0
  154. package/dist/ui/help-overlay.d.ts +16 -0
  155. package/dist/ui/help-overlay.js +316 -0
  156. package/dist/ui/index.d.ts +1 -1
  157. package/dist/ui/index.js +1 -3
  158. package/dist/ui/init-overlay-v2.d.ts +34 -0
  159. package/dist/ui/init-overlay-v2.js +600 -0
  160. package/dist/ui/init-overlay.d.ts +12 -2
  161. package/dist/ui/init-overlay.js +349 -270
  162. package/dist/ui/input-prompt-v2.d.ts +1 -0
  163. package/dist/ui/input-prompt-v2.js +14 -6
  164. package/dist/ui/input-prompt.d.ts +116 -33
  165. package/dist/ui/input-prompt.js +536 -337
  166. package/dist/ui/iteration-limit-overlay-v2.d.ts +21 -0
  167. package/dist/ui/iteration-limit-overlay-v2.js +114 -0
  168. package/dist/ui/iteration-limit-overlay.d.ts +2 -2
  169. package/dist/ui/iteration-limit-overlay.js +92 -128
  170. package/dist/ui/keys-overlay-v2.d.ts +41 -0
  171. package/dist/ui/keys-overlay-v2.js +248 -0
  172. package/dist/ui/keys-overlay.d.ts +1 -0
  173. package/dist/ui/keys-overlay.js +203 -141
  174. package/dist/ui/line-utils.d.ts +88 -0
  175. package/dist/ui/line-utils.js +150 -0
  176. package/dist/ui/live-region.d.ts +161 -0
  177. package/dist/ui/live-region.js +387 -0
  178. package/dist/ui/mascot/expressions.d.ts +32 -0
  179. package/dist/ui/mascot/expressions.js +213 -0
  180. package/dist/ui/mascot/index.d.ts +8 -0
  181. package/dist/ui/mascot/index.js +8 -0
  182. package/dist/ui/mascot/renderer.d.ts +19 -0
  183. package/dist/ui/mascot/renderer.js +97 -0
  184. package/dist/ui/mascot-overlay-v2.d.ts +41 -0
  185. package/dist/ui/mascot-overlay-v2.js +138 -0
  186. package/dist/ui/mascot-overlay.d.ts +21 -0
  187. package/dist/ui/mascot-overlay.js +146 -0
  188. package/dist/ui/model-overlay-v2.d.ts +49 -0
  189. package/dist/ui/model-overlay-v2.js +118 -0
  190. package/dist/ui/model-overlay.d.ts +27 -0
  191. package/dist/ui/model-overlay.js +221 -0
  192. package/dist/ui/model-warning-overlay.js +3 -5
  193. package/dist/ui/new-overlay.d.ts +34 -0
  194. package/dist/ui/new-overlay.js +604 -0
  195. package/dist/ui/overlay/impl/agents-overlay-v2.d.ts +45 -0
  196. package/dist/ui/overlay/impl/agents-overlay-v2.js +825 -0
  197. package/dist/ui/overlay/impl/anchors-overlay-v2.d.ts +47 -0
  198. package/dist/ui/overlay/impl/anchors-overlay-v2.js +783 -0
  199. package/dist/ui/overlay/impl/arch-type-overlay-v2.d.ts +37 -0
  200. package/dist/ui/overlay/impl/arch-type-overlay-v2.js +240 -0
  201. package/dist/ui/overlay/impl/ask-user-overlay-v2.d.ts +72 -0
  202. package/dist/ui/overlay/impl/ask-user-overlay-v2.js +584 -0
  203. package/dist/ui/overlay/impl/ask-user-simple-overlay-v2.d.ts +46 -0
  204. package/dist/ui/overlay/impl/ask-user-simple-overlay-v2.js +204 -0
  205. package/dist/ui/overlay/impl/backlog-overlay-v2.d.ts +49 -0
  206. package/dist/ui/overlay/impl/backlog-overlay-v2.js +642 -0
  207. package/dist/ui/overlay/impl/commands-overlay-v2.d.ts +33 -0
  208. package/dist/ui/overlay/impl/commands-overlay-v2.js +441 -0
  209. package/dist/ui/overlay/impl/config-overlay-v2.d.ts +100 -0
  210. package/dist/ui/overlay/impl/config-overlay-v2.js +654 -0
  211. package/dist/ui/overlay/impl/dashboard-overlay-v2.d.ts +55 -0
  212. package/dist/ui/overlay/impl/dashboard-overlay-v2.js +359 -0
  213. package/dist/ui/overlay/impl/docs-overlay-v2.d.ts +45 -0
  214. package/dist/ui/overlay/impl/docs-overlay-v2.js +114 -0
  215. package/dist/ui/overlay/impl/document-detail-overlay-v2.d.ts +77 -0
  216. package/dist/ui/overlay/impl/document-detail-overlay-v2.js +1071 -0
  217. package/dist/ui/overlay/impl/guardrail-overlay-v2.d.ts +43 -0
  218. package/dist/ui/overlay/impl/guardrail-overlay-v2.js +114 -0
  219. package/dist/ui/overlay/impl/help-overlay-v2.d.ts +34 -0
  220. package/dist/ui/overlay/impl/help-overlay-v2.js +309 -0
  221. package/dist/ui/overlay/impl/init-overlay-v2.d.ts +77 -0
  222. package/dist/ui/overlay/impl/init-overlay-v2.js +593 -0
  223. package/dist/ui/overlay/impl/init-setup-overlay-v2.d.ts +25 -0
  224. package/dist/ui/overlay/impl/init-setup-overlay-v2.js +97 -0
  225. package/dist/ui/overlay/impl/iteration-limit-overlay-v2.d.ts +35 -0
  226. package/dist/ui/overlay/impl/iteration-limit-overlay-v2.js +105 -0
  227. package/dist/ui/overlay/impl/keys-overlay-v2.d.ts +41 -0
  228. package/dist/ui/overlay/impl/keys-overlay-v2.js +248 -0
  229. package/dist/ui/overlay/impl/mascot-overlay-v2.d.ts +41 -0
  230. package/dist/ui/overlay/impl/mascot-overlay-v2.js +138 -0
  231. package/dist/ui/overlay/impl/model-overlay-v2.d.ts +49 -0
  232. package/dist/ui/overlay/impl/model-overlay-v2.js +118 -0
  233. package/dist/ui/overlay/impl/model-warning-overlay-v2.d.ts +46 -0
  234. package/dist/ui/overlay/impl/model-warning-overlay-v2.js +132 -0
  235. package/dist/ui/overlay/impl/new-overlay-v2.d.ts +77 -0
  236. package/dist/ui/overlay/impl/new-overlay-v2.js +593 -0
  237. package/dist/ui/overlay/impl/permission-overlay-v2.d.ts +36 -0
  238. package/dist/ui/overlay/impl/permission-overlay-v2.js +380 -0
  239. package/dist/ui/overlay/impl/projects-overlay-v2.d.ts +36 -0
  240. package/dist/ui/overlay/impl/projects-overlay-v2.js +499 -0
  241. package/dist/ui/overlay/impl/theme-overlay-v2.d.ts +42 -0
  242. package/dist/ui/overlay/impl/theme-overlay-v2.js +135 -0
  243. package/dist/ui/overlay/impl/tools-overlay-v2.d.ts +47 -0
  244. package/dist/ui/overlay/impl/tools-overlay-v2.js +218 -0
  245. package/dist/ui/overlay/impl/tutorial-overlay-v2.d.ts +31 -0
  246. package/dist/ui/overlay/impl/tutorial-overlay-v2.js +1035 -0
  247. package/dist/ui/overlay/impl/workflow-overlay-v2.d.ts +80 -0
  248. package/dist/ui/overlay/impl/workflow-overlay-v2.js +637 -0
  249. package/dist/ui/overlay/index.d.ts +33 -0
  250. package/dist/ui/overlay/index.js +35 -0
  251. package/dist/ui/overlay/key-utils.d.ts +6 -0
  252. package/dist/ui/overlay/key-utils.js +6 -0
  253. package/dist/ui/overlay/overlay-types.d.ts +128 -0
  254. package/dist/ui/overlay/overlay-types.js +22 -0
  255. package/dist/ui/overlay/types.d.ts +135 -0
  256. package/dist/ui/overlay/types.js +22 -0
  257. package/dist/ui/overlays/help-overlay-v2.d.ts +28 -0
  258. package/dist/ui/overlays/help-overlay-v2.js +198 -0
  259. package/dist/ui/overlays/index.d.ts +11 -0
  260. package/dist/ui/overlays/index.js +11 -0
  261. package/dist/ui/overlays.d.ts +0 -4
  262. package/dist/ui/overlays.js +0 -444
  263. package/dist/ui/permission-overlay-v2.d.ts +36 -0
  264. package/dist/ui/permission-overlay-v2.js +380 -0
  265. package/dist/ui/permission-overlay.d.ts +1 -1
  266. package/dist/ui/permission-overlay.js +186 -298
  267. package/dist/ui/projects-overlay.d.ts +19 -0
  268. package/dist/ui/projects-overlay.js +484 -0
  269. package/dist/ui/providers/types.d.ts +178 -0
  270. package/dist/ui/providers/types.js +9 -0
  271. package/dist/ui/render-modes.d.ts +36 -0
  272. package/dist/ui/render-modes.js +44 -0
  273. package/dist/ui/startup-menu.d.ts +36 -0
  274. package/dist/ui/startup-menu.js +236 -0
  275. package/dist/ui/subagent-renderer.d.ts +117 -0
  276. package/dist/ui/subagent-renderer.js +334 -0
  277. package/dist/ui/terminal-codes.d.ts +94 -0
  278. package/dist/ui/terminal-codes.js +124 -0
  279. package/dist/ui/terminal-renderer.d.ts +221 -0
  280. package/dist/ui/terminal-renderer.js +751 -0
  281. package/dist/ui/terminal-ui.d.ts +463 -0
  282. package/dist/ui/terminal-ui.js +2296 -0
  283. package/dist/ui/terminal.d.ts +20 -0
  284. package/dist/ui/terminal.js +72 -0
  285. package/dist/ui/theme-overlay-v2.d.ts +42 -0
  286. package/dist/ui/theme-overlay-v2.js +135 -0
  287. package/dist/ui/theme-overlay.d.ts +24 -0
  288. package/dist/ui/theme-overlay.js +127 -0
  289. package/dist/ui/todo-zone.js +53 -25
  290. package/dist/ui/tool-formatters.d.ts +16 -0
  291. package/dist/ui/tool-formatters.js +516 -0
  292. package/dist/ui/tools-overlay-v2.d.ts +47 -0
  293. package/dist/ui/tools-overlay-v2.js +218 -0
  294. package/dist/ui/tools-overlay.d.ts +10 -2
  295. package/dist/ui/tools-overlay.js +172 -220
  296. package/dist/ui/tutorial-overlay-v2.d.ts +31 -0
  297. package/dist/ui/tutorial-overlay-v2.js +1035 -0
  298. package/dist/ui/tutorial-overlay.d.ts +1 -0
  299. package/dist/ui/tutorial-overlay.js +400 -302
  300. package/dist/ui/workflow-overlay.d.ts +22 -0
  301. package/dist/ui/workflow-overlay.js +636 -0
  302. package/dist/utils/debug-log.d.ts +28 -0
  303. package/dist/utils/debug-log.js +57 -0
  304. package/dist/utils/model-tiers.js +1 -1
  305. package/dist/utils/path-safety.d.ts +56 -0
  306. package/dist/utils/path-safety.js +239 -0
  307. package/dist/workflow/guided-mode-injector.d.ts +42 -0
  308. package/dist/workflow/guided-mode-injector.js +191 -0
  309. package/dist/workflow/index.d.ts +8 -0
  310. package/dist/workflow/index.js +8 -0
  311. package/dist/workflow/step-criteria.d.ts +62 -0
  312. package/dist/workflow/step-criteria.js +150 -0
  313. package/dist/workflow/step-tracker.d.ts +92 -0
  314. package/dist/workflow/step-tracker.js +141 -0
  315. package/package.json +12 -5
@@ -0,0 +1,2296 @@
1
+ /**
2
+ * TerminalUI - Single Renderer for Terminal Applications
3
+ *
4
+ * Single point of control for all terminal UI:
5
+ * - Owns all rendering decisions (when to clear, when to render)
6
+ * - Owns all UI state (agentRunning, todos, currentTool, etc.)
7
+ * - Provides output methods that handle clear/render automatically
8
+ * - Manages overlays (render, input routing)
9
+ * - REPL emits events, doesn't manage rendering timing
10
+ *
11
+ * Key principles:
12
+ * - NO scroll regions (preserves terminal scrollback)
13
+ * - Deterministic render cycle
14
+ * - All output goes through TerminalUI (no external console.log)
15
+ */
16
+ import { EventEmitter } from 'events';
17
+ import * as readline from 'readline';
18
+ import * as terminal from './terminal.js';
19
+ import { getPhysicalLineCount, getVisibleLength } from './line-utils.js';
20
+ import { getStyles } from '../themes/index.js';
21
+ import { MODE_INFO } from './types.js';
22
+ import { getAutocompleteCommands } from '../commands.js';
23
+ import { getFileMatches, extractAtMention, replaceAtMention, } from './file-autocomplete.js';
24
+ import { LiveRegion } from './live-region.js';
25
+ import { renderMarkdown } from './conversation.js';
26
+ export const DEFAULT_TERMINAL_UI_CONFIG = {
27
+ verbose: false,
28
+ theme: 'default',
29
+ showMascot: true,
30
+ };
31
+ // =============================================================================
32
+ // Constants
33
+ // =============================================================================
34
+ const MAX_VISIBLE_COMMANDS = 10;
35
+ // =============================================================================
36
+ // Mascot Expressions
37
+ // =============================================================================
38
+ /**
39
+ * Inline mascot expressions for agent identity and state feedback.
40
+ * Used to prefix agent messages and in spinner animations.
41
+ */
42
+ export const MASCOT = {
43
+ // Core expressions
44
+ neutral: '[•_•]', // Default state - regular messages
45
+ thinking: '[°_°]', // Processing/thinking
46
+ searching: '[◐_◐]', // Searching/scanning files
47
+ success: '[^_^]', // Task completed successfully
48
+ error: '[×_×]', // Error occurred
49
+ confused: '[?_?]', // Needs clarification
50
+ working: '[•̀_•́]', // Actively working on task
51
+ // CRT monitor animation frames (subtle scanner effect)
52
+ crt: ['[░░░]', '[▒░░]', '[░▒░]', '[░░▒]', '[░▒░]', '[▒░░]'],
53
+ };
54
+ // =============================================================================
55
+ // Fuzzy Matching
56
+ // =============================================================================
57
+ /**
58
+ * Calculate fuzzy match score for a query against a target string.
59
+ * Higher score = better match. Returns -1 if no match.
60
+ */
61
+ function fuzzyMatchScore(query, target) {
62
+ const queryLower = query.toLowerCase();
63
+ const targetLower = target.toLowerCase();
64
+ // Exact prefix match - highest priority (score 1000+)
65
+ if (targetLower.startsWith(queryLower)) {
66
+ return 1000 + (100 - target.length); // Shorter commands rank higher
67
+ }
68
+ // Contiguous substring match - high priority (score 500+)
69
+ if (targetLower.includes(queryLower)) {
70
+ const index = targetLower.indexOf(queryLower);
71
+ return 500 + (100 - index); // Earlier matches rank higher
72
+ }
73
+ // Fuzzy match - characters appear in order (score 100+)
74
+ let queryIdx = 0;
75
+ let consecutiveBonus = 0;
76
+ let lastMatchIdx = -1;
77
+ for (let i = 0; i < targetLower.length && queryIdx < queryLower.length; i++) {
78
+ if (targetLower[i] === queryLower[queryIdx]) {
79
+ if (lastMatchIdx === i - 1) {
80
+ consecutiveBonus += 10;
81
+ }
82
+ lastMatchIdx = i;
83
+ queryIdx++;
84
+ }
85
+ }
86
+ if (queryIdx === queryLower.length) {
87
+ return 100 + consecutiveBonus + (100 - target.length);
88
+ }
89
+ return -1;
90
+ }
91
+ /**
92
+ * Filter and rank commands matching input using fuzzy matching
93
+ */
94
+ function filterCommands(input, commands) {
95
+ const scored = commands
96
+ .map((cmd) => ({
97
+ cmd,
98
+ score: fuzzyMatchScore(input, cmd.command),
99
+ }))
100
+ .filter((item) => item.score >= 0);
101
+ scored.sort((a, b) => b.score - a.score);
102
+ return scored.map((item) => item.cmd);
103
+ }
104
+ // =============================================================================
105
+ // Footer V2 Class
106
+ // =============================================================================
107
+ export class TerminalUI extends EventEmitter {
108
+ // Configuration
109
+ promptPrefix;
110
+ promptPrefixLen;
111
+ config;
112
+ // Conversation history (for re-rendering on config change)
113
+ conversationHistory = [];
114
+ // Input state (multiline)
115
+ lines = [''];
116
+ currentLine = 0;
117
+ cursorPos = 0;
118
+ mode;
119
+ projectName = null;
120
+ todos = [];
121
+ spinnerText = null;
122
+ spinnerFrame = 0;
123
+ agentRunning = false;
124
+ queuedInputs = [];
125
+ agentMessageQueue = [];
126
+ currentTool = null;
127
+ // LiveRegion for running tools (bash, subagents)
128
+ liveRegion = new LiveRegion();
129
+ // Command autocomplete state (for /commands)
130
+ autocomplete = {
131
+ active: false,
132
+ matches: [],
133
+ selectedIndex: 0,
134
+ scrollOffset: 0,
135
+ };
136
+ // File autocomplete state (for @paths)
137
+ fileAutocomplete = {
138
+ active: false,
139
+ matches: [],
140
+ selectedIndex: 0,
141
+ scrollOffset: 0,
142
+ partial: '',
143
+ };
144
+ // History state
145
+ history = [];
146
+ historyIndex = -1;
147
+ savedInput = '';
148
+ // Render tracking
149
+ lastRenderHeight = 0;
150
+ cursorLineFromBottom = 0;
151
+ isRunning = false;
152
+ isPaused = false;
153
+ renderTimer = null;
154
+ needsRender = false;
155
+ // Double Esc detection
156
+ lastEscapeTime = 0;
157
+ // Ghost text suggestion
158
+ suggestion = null;
159
+ // Todo visibility (Ctrl+T to toggle)
160
+ showTodos = true;
161
+ // View mode (Ctrl+O to toggle verbose view)
162
+ // normal: compact output
163
+ // verbose-temp: temporarily show last N items verbose (any key returns to normal)
164
+ viewMode = 'normal';
165
+ // Spinner animation - CRT monitor fill effect
166
+ spinnerFrames = MASCOT.crt;
167
+ spinnerTimer = null;
168
+ // Overlay stack (supports nested overlays)
169
+ overlayStack = [];
170
+ overlayResolvers = new Map();
171
+ overlayRenderState = { lineCount: 0, maxLineCount: 0 };
172
+ // Buffer for items printed while overlay is active (to prevent cursor corruption)
173
+ overlayPrintBuffer = [];
174
+ constructor(options = {}) {
175
+ super();
176
+ const s = getStyles();
177
+ this.promptPrefix = options.prompt ?? s.primaryBold('compilr>') + ' ';
178
+ this.promptPrefixLen = getVisibleLength(this.promptPrefix);
179
+ this.mode = options.initialMode ?? 'normal';
180
+ this.config = { ...DEFAULT_TERMINAL_UI_CONFIG, ...options.config };
181
+ }
182
+ // ===========================================================================
183
+ // Input value helpers
184
+ // ===========================================================================
185
+ /** Get the full input value (all lines joined with newlines) */
186
+ getInputValue() {
187
+ return this.lines.join('\n');
188
+ }
189
+ /** Get the current line content */
190
+ getCurrentLineContent() {
191
+ return this.lines[this.currentLine];
192
+ }
193
+ /** Clear input and reset to single empty line */
194
+ clearInput() {
195
+ this.lines = [''];
196
+ this.currentLine = 0;
197
+ this.cursorPos = 0;
198
+ }
199
+ // ===========================================================================
200
+ // Lifecycle
201
+ // ===========================================================================
202
+ start() {
203
+ if (this.isRunning)
204
+ return;
205
+ this.isRunning = true;
206
+ this.isPaused = false;
207
+ // Start keyboard input handling
208
+ this.startKeyboardCapture();
209
+ // Initial render
210
+ this.render();
211
+ // Start render loop (60ms = ~16fps)
212
+ this.renderTimer = setInterval(() => {
213
+ if (this.needsRender && !this.isPaused) {
214
+ this.render();
215
+ this.needsRender = false;
216
+ }
217
+ }, 60);
218
+ }
219
+ stop() {
220
+ if (!this.isRunning)
221
+ return;
222
+ this.isRunning = false;
223
+ // Stop render loop
224
+ if (this.renderTimer) {
225
+ clearInterval(this.renderTimer);
226
+ this.renderTimer = null;
227
+ }
228
+ // Stop spinner
229
+ this.stopSpinnerAnimation();
230
+ // Stop keyboard capture
231
+ this.stopKeyboardCapture();
232
+ // Clear footer
233
+ this.clear();
234
+ }
235
+ // ===========================================================================
236
+ // Public API - Output (THE KEY METHODS)
237
+ // ===========================================================================
238
+ /**
239
+ * Print a semantic item to the scrolling zone.
240
+ * Items are stored in conversation history for re-rendering when config changes.
241
+ *
242
+ * IMPORTANT: When an overlay is active, items are buffered and rendered after
243
+ * the overlay closes. This prevents cursor position corruption that causes
244
+ * ghost lines and visual artifacts.
245
+ */
246
+ print(item) {
247
+ // Store in history for re-render capability
248
+ this.conversationHistory.push(item);
249
+ // If overlay is active, buffer the item to render later
250
+ // This prevents output from corrupting overlay cursor tracking
251
+ if (this.hasActiveOverlay()) {
252
+ this.overlayPrintBuffer.push(item);
253
+ return;
254
+ }
255
+ // IMPORTANT: If needsRender is true, render FIRST to update lastRenderHeight.
256
+ // This prevents ghost lines when footer height changed (e.g., spinner started)
257
+ // but render loop hasn't fired yet. Without this, clear() uses stale height.
258
+ if (this.needsRender) {
259
+ this.render();
260
+ this.needsRender = false;
261
+ }
262
+ // Clear footer, render item, re-render footer
263
+ this.clear();
264
+ this.renderItem(item);
265
+ this.render();
266
+ }
267
+ /**
268
+ * Render a single item to the console.
269
+ * Config-aware: respects verbose, showMascot settings.
270
+ */
271
+ renderItem(item) {
272
+ const s = getStyles();
273
+ switch (item.type) {
274
+ case 'user-message':
275
+ console.log('');
276
+ console.log(s.primaryBold('> ') + item.text);
277
+ console.log(''); // Trailing blank for separation
278
+ break;
279
+ case 'agent-text': {
280
+ // Render markdown for proper formatting (headers, lists, bold, code, etc.)
281
+ const rendered = renderMarkdown(item.text);
282
+ // Prefix agent messages with mascot expression (if enabled)
283
+ if (this.config.showMascot) {
284
+ const expr = item.expression ? MASCOT[item.expression] : MASCOT.neutral;
285
+ // Only prefix the first line with mascot
286
+ const lines = rendered.split('\n');
287
+ if (lines.length > 0) {
288
+ console.log(s.primary(expr) + s.muted(' > ') + lines[0]);
289
+ for (let i = 1; i < lines.length; i++) {
290
+ console.log(lines[i]);
291
+ }
292
+ }
293
+ }
294
+ else {
295
+ console.log(rendered);
296
+ }
297
+ console.log(''); // Trailing blank for separation
298
+ break;
299
+ }
300
+ case 'thinking':
301
+ // Only show thinking in verbose mode
302
+ if (this.config.verbose) {
303
+ console.log(s.muted(`∴ Thinking…`));
304
+ console.log('');
305
+ // Indent thinking text
306
+ const lines = item.text.split('\n');
307
+ for (const line of lines) {
308
+ console.log(s.muted(` ${line}`));
309
+ }
310
+ console.log('');
311
+ }
312
+ // If not verbose, skip entirely (but still stored in history)
313
+ break;
314
+ case 'tool-start': {
315
+ // Truncate long params (e.g., long bash commands)
316
+ const maxLen = Math.min(60, terminal.getTerminalWidth() - 15);
317
+ let params = item.params;
318
+ // Handle multi-line params
319
+ const nlIdx = params.indexOf('\n');
320
+ if (nlIdx !== -1) {
321
+ const first = params.slice(0, nlIdx).trim();
322
+ const count = params.split('\n').length;
323
+ params = first.length > 0 ? `${first}… (${String(count)} lines)` : `(${String(count)} line script)`;
324
+ }
325
+ // Truncate if still too long
326
+ if (params.length > maxLen) {
327
+ params = params.slice(0, maxLen) + '…';
328
+ }
329
+ console.log(s.info(`● ${item.name}`) + s.muted(`(${params})`));
330
+ break;
331
+ }
332
+ case 'tool-result': {
333
+ // Truncate long params (e.g., long bash commands)
334
+ const maxParamsLen = Math.min(60, terminal.getTerminalWidth() - 15);
335
+ let paramsDisplay = item.params;
336
+ // Handle multi-line params (show first line + count)
337
+ const newlineIdx = paramsDisplay.indexOf('\n');
338
+ if (newlineIdx !== -1) {
339
+ const firstLine = paramsDisplay.slice(0, newlineIdx).trim();
340
+ const lineCount = paramsDisplay.split('\n').length;
341
+ paramsDisplay = firstLine.length > 0
342
+ ? `${firstLine}… (${String(lineCount)} lines)`
343
+ : `(${String(lineCount)} line script)`;
344
+ }
345
+ // Truncate if still too long
346
+ if (paramsDisplay.length > maxParamsLen) {
347
+ paramsDisplay = paramsDisplay.slice(0, maxParamsLen) + '…';
348
+ }
349
+ console.log(s.info(`● ${item.name}`) + s.muted(`(${paramsDisplay})`));
350
+ if (item.content) {
351
+ const lines = item.content.split('\n').filter((l) => l.length > 0);
352
+ const maxPreviewLines = 3;
353
+ if (this.config.verbose) {
354
+ // Verbose: show all lines
355
+ for (const line of lines) {
356
+ console.log(s.muted(` ⎿ ${line}`));
357
+ }
358
+ }
359
+ else if (lines.length > 0) {
360
+ // Compact: show first few lines + hidden count
361
+ const previewLines = lines.slice(0, maxPreviewLines);
362
+ const hiddenCount = lines.length - maxPreviewLines;
363
+ for (let i = 0; i < previewLines.length; i++) {
364
+ if (i === 0) {
365
+ console.log(s.muted(` ⎿ ${previewLines[i]}`));
366
+ }
367
+ else {
368
+ console.log(s.muted(` ${previewLines[i]}`));
369
+ }
370
+ }
371
+ if (hiddenCount > 0) {
372
+ console.log(s.muted(` … +${String(hiddenCount)} lines`) + s.muted(' (ctrl+o to expand)'));
373
+ }
374
+ }
375
+ else {
376
+ // No output lines
377
+ const expandHint = s.muted(' (ctrl+o to expand)');
378
+ if (item.success === false) {
379
+ console.log(s.error(` ⎿ ${item.summary}`) + expandHint);
380
+ }
381
+ else {
382
+ console.log(s.muted(` ⎿ ${item.summary}`) + expandHint);
383
+ }
384
+ }
385
+ }
386
+ else {
387
+ // No content, just show summary
388
+ if (item.success === false) {
389
+ console.log(s.error(` ⎿ ${item.summary}`));
390
+ }
391
+ else {
392
+ console.log(s.muted(` ⎿ ${item.summary}`));
393
+ }
394
+ }
395
+ console.log('');
396
+ break;
397
+ }
398
+ case 'tool-error': {
399
+ // Truncate long params
400
+ const maxErrLen = Math.min(60, terminal.getTerminalWidth() - 15);
401
+ let errParams = item.params;
402
+ const errNlIdx = errParams.indexOf('\n');
403
+ if (errNlIdx !== -1) {
404
+ const errFirst = errParams.slice(0, errNlIdx).trim();
405
+ const errCount = errParams.split('\n').length;
406
+ errParams = errFirst.length > 0 ? `${errFirst}… (${String(errCount)} lines)` : `(${String(errCount)} line script)`;
407
+ }
408
+ if (errParams.length > maxErrLen) {
409
+ errParams = errParams.slice(0, maxErrLen) + '…';
410
+ }
411
+ console.log(s.info(`● ${item.name}`) + s.muted(`(${errParams})`));
412
+ console.log(s.error(` ⎿ Error: ${item.error}`));
413
+ console.log('');
414
+ break;
415
+ }
416
+ case 'interrupted': {
417
+ // Show what was ongoing (if provided)
418
+ if (item.action) {
419
+ console.log(s.info(`● ${item.action}`));
420
+ }
421
+ else {
422
+ // No action means interrupted right after user message.
423
+ // Move cursor up to consume the trailing blank from user-message.
424
+ //
425
+ // ⚠️ WARNING: This cursor manipulation is fragile!
426
+ // It assumes the previous item was user-message which adds a trailing blank.
427
+ // If we add items that don't add trailing blanks, or if the rendering
428
+ // order changes, this could cause visual artifacts (overwriting content).
429
+ // If issues arise, consider tracking the last printed item type instead.
430
+ process.stdout.write('\x1b[1A'); // Move up one line
431
+ }
432
+ // Show interrupted line with mascot
433
+ const suggestion = item.suggestion ?? 'What should I do instead?';
434
+ console.log(s.warning(` ⎿ ${MASCOT.confused} Interrupted - ${suggestion}`));
435
+ console.log('');
436
+ // Also set as ghost text suggestion
437
+ this.setSuggestion(suggestion);
438
+ break;
439
+ }
440
+ case 'error':
441
+ console.log(s.error(`${MASCOT.error} ${item.message}`));
442
+ console.log('');
443
+ break;
444
+ case 'success':
445
+ console.log(s.success(`${MASCOT.success} ${item.message}`));
446
+ console.log('');
447
+ break;
448
+ case 'info':
449
+ console.log(s.info(item.message));
450
+ console.log('');
451
+ break;
452
+ case 'warning':
453
+ console.log(s.warning(item.message));
454
+ console.log('');
455
+ break;
456
+ case 'raw':
457
+ console.log(item.text);
458
+ break;
459
+ case 'raw-lines':
460
+ for (const line of item.lines) {
461
+ console.log(line);
462
+ }
463
+ break;
464
+ }
465
+ }
466
+ /**
467
+ * Execute a callback that outputs to terminal.
468
+ * Handles clear → callback → render automatically.
469
+ * Use this for complex output that needs multiple console.log calls.
470
+ * @deprecated Prefer using print() with PrintableItem
471
+ */
472
+ output(callback) {
473
+ this.clear();
474
+ callback();
475
+ this.render();
476
+ }
477
+ // ===========================================================================
478
+ // Public API - State setters
479
+ // ===========================================================================
480
+ setAgentRunning(running) {
481
+ const wasRunning = this.agentRunning;
482
+ this.agentRunning = running;
483
+ if (running && !wasRunning) {
484
+ this.startSpinnerAnimation();
485
+ // Let render loop handle it
486
+ }
487
+ else if (!running && wasRunning) {
488
+ this.stopSpinnerAnimation();
489
+ this.currentTool = null;
490
+ }
491
+ this.needsRender = true;
492
+ }
493
+ isAgentRunning() {
494
+ return this.agentRunning;
495
+ }
496
+ setTodos(todos) {
497
+ this.todos = todos;
498
+ this.needsRender = true;
499
+ }
500
+ getTodos() {
501
+ return [...this.todos];
502
+ }
503
+ setSpinnerText(text) {
504
+ this.spinnerText = text;
505
+ this.needsRender = true;
506
+ }
507
+ setCurrentTool(tool) {
508
+ this.currentTool = tool;
509
+ if (tool) {
510
+ this.spinnerText = tool;
511
+ }
512
+ this.needsRender = true;
513
+ }
514
+ setMode(mode) {
515
+ this.mode = mode;
516
+ this.needsRender = true;
517
+ }
518
+ getMode() {
519
+ return this.mode;
520
+ }
521
+ setProjectName(name) {
522
+ this.projectName = name;
523
+ this.needsRender = true;
524
+ }
525
+ getProjectName() {
526
+ return this.projectName;
527
+ }
528
+ /**
529
+ * Set ghost text suggestion (shown when input is empty)
530
+ */
531
+ setSuggestion(text) {
532
+ this.suggestion = text;
533
+ this.needsRender = true;
534
+ }
535
+ getSuggestion() {
536
+ return this.suggestion;
537
+ }
538
+ clearSuggestion() {
539
+ this.suggestion = null;
540
+ this.needsRender = true;
541
+ }
542
+ // ===========================================================================
543
+ // Public API - LiveRegion (subagents, bash commands)
544
+ // ===========================================================================
545
+ /**
546
+ * Add a subagent to the live region.
547
+ */
548
+ addSubagent(id, agentType, description) {
549
+ const item = {
550
+ id,
551
+ type: 'subagent',
552
+ status: 'running',
553
+ startTime: Date.now(),
554
+ agentType,
555
+ description,
556
+ toolCount: 0,
557
+ tokenCount: 0,
558
+ lastAction: '',
559
+ lastActionDetails: [],
560
+ };
561
+ this.liveRegion.addItem(item);
562
+ // Let render loop handle it (same as addBashCommand)
563
+ this.needsRender = true;
564
+ }
565
+ /**
566
+ * Update a subagent's tool usage.
567
+ */
568
+ updateSubagentTool(id, toolName, summary) {
569
+ const item = this.liveRegion.getItem(id);
570
+ if (item && item.type === 'subagent') {
571
+ const newAction = `${toolName}: ${summary}`;
572
+ // Add previous action to history (for expanded view)
573
+ const updatedDetails = item.lastAction
574
+ ? [...item.lastActionDetails, item.lastAction]
575
+ : [...item.lastActionDetails];
576
+ this.liveRegion.updateItem(id, {
577
+ toolCount: item.toolCount + 1,
578
+ lastAction: newAction,
579
+ lastActionDetails: updatedDetails,
580
+ });
581
+ this.needsRender = true;
582
+ }
583
+ }
584
+ /**
585
+ * Mark a subagent as completed.
586
+ */
587
+ completeSubagent(id, success, tokenCount, _error) {
588
+ const item = this.liveRegion.getItem(id);
589
+ if (item && item.type === 'subagent') {
590
+ this.liveRegion.updateItem(id, {
591
+ status: success ? 'done' : 'error',
592
+ tokenCount: tokenCount ?? item.tokenCount,
593
+ endTime: Date.now(),
594
+ });
595
+ this.liveRegion.completeItem(id, success ? 'done' : 'error');
596
+ this.needsRender = true;
597
+ }
598
+ }
599
+ /**
600
+ * Remove a subagent and return it for committing to scrolling zone.
601
+ */
602
+ removeSubagent(id) {
603
+ const item = this.liveRegion.removeItem(id);
604
+ this.needsRender = true;
605
+ return item;
606
+ }
607
+ /**
608
+ * Clear all subagents from live region.
609
+ */
610
+ clearLiveRegion() {
611
+ this.liveRegion.clear();
612
+ this.needsRender = true;
613
+ }
614
+ /**
615
+ * Toggle expanded view in live region (Ctrl+O).
616
+ */
617
+ toggleLiveRegionExpanded() {
618
+ this.liveRegion.toggleExpanded();
619
+ this.needsRender = true;
620
+ }
621
+ /**
622
+ * Check if live region has items.
623
+ */
624
+ hasLiveItems() {
625
+ return this.liveRegion.hasItems();
626
+ }
627
+ /**
628
+ * Get live region for direct access if needed.
629
+ */
630
+ getLiveRegion() {
631
+ return this.liveRegion;
632
+ }
633
+ // ===========================================================================
634
+ // Live Region - Bash Commands
635
+ // ===========================================================================
636
+ /**
637
+ * Add a bash command to the live region.
638
+ */
639
+ addBashCommand(id, command) {
640
+ const item = {
641
+ id,
642
+ type: 'bash',
643
+ status: 'running',
644
+ startTime: Date.now(),
645
+ command,
646
+ output: [],
647
+ };
648
+ this.liveRegion.addItem(item);
649
+ this.needsRender = true;
650
+ }
651
+ /**
652
+ * Add output line to a bash command.
653
+ */
654
+ updateBashOutput(id, line) {
655
+ const item = this.liveRegion.getItem(id);
656
+ if (item && item.type === 'bash') {
657
+ item.output.push(line);
658
+ this.liveRegion.updateItem(id, { output: [...item.output] });
659
+ this.needsRender = true;
660
+ }
661
+ }
662
+ /**
663
+ * Mark a bash command as completed.
664
+ */
665
+ completeBashCommand(id, exitCode) {
666
+ const item = this.liveRegion.getItem(id);
667
+ if (item && item.type === 'bash') {
668
+ this.liveRegion.updateItem(id, {
669
+ status: exitCode === 0 ? 'done' : 'error',
670
+ exitCode,
671
+ endTime: Date.now(),
672
+ });
673
+ this.liveRegion.completeItem(id, exitCode === 0 ? 'done' : 'error');
674
+ this.needsRender = true;
675
+ }
676
+ }
677
+ /**
678
+ * Remove a bash command and commit it to the scrolling zone.
679
+ */
680
+ commitBashCommand(id) {
681
+ const item = this.liveRegion.removeItem(id);
682
+ if (item && item.type === 'bash') {
683
+ const output = item.output.join('\n') || '(no output)';
684
+ const exitCode = item.exitCode ?? 0;
685
+ // Print the bash output to scrolling zone
686
+ this.print({
687
+ type: 'tool-result',
688
+ name: 'Bash',
689
+ params: item.command,
690
+ summary: exitCode === 0 ? 'Completed' : `Exit code ${String(exitCode)}`,
691
+ content: output,
692
+ success: exitCode === 0,
693
+ });
694
+ }
695
+ this.needsRender = true;
696
+ }
697
+ /**
698
+ * Remove a subagent and commit it to the scrolling zone.
699
+ * This is the atomic equivalent of commitBashCommand for subagents.
700
+ * The result content comes from tool_end, not from the LiveRegion item.
701
+ */
702
+ commitSubagent(id, resultContent, success) {
703
+ // Remove from LiveRegion FIRST (no needsRender yet - that's the key!)
704
+ const item = this.liveRegion.removeItem(id);
705
+ if (item && item.type === 'subagent') {
706
+ // Build summary from item stats
707
+ const toolCount = item.toolCount;
708
+ const tokenCount = item.tokenCount;
709
+ const duration = item.endTime
710
+ ? Math.round((item.endTime - item.startTime) / 1000)
711
+ : Math.round((Date.now() - item.startTime) / 1000);
712
+ const tokenStr = tokenCount > 0 ? `, ${this.formatTokens(tokenCount)} tokens` : '';
713
+ const summary = `${String(toolCount)} tool calls${tokenStr}, ${String(duration)}s`;
714
+ // Print atomically (uses current lastRenderHeight before it changes)
715
+ this.print({
716
+ type: 'tool-result',
717
+ name: item.agentType,
718
+ params: item.description,
719
+ summary,
720
+ content: resultContent.length > 200 ? resultContent : undefined,
721
+ success,
722
+ });
723
+ // Reset spinner text
724
+ this.spinnerText = null;
725
+ }
726
+ // Set needsRender AFTER print (same pattern as commitBashCommand)
727
+ this.needsRender = true;
728
+ return item;
729
+ }
730
+ /**
731
+ * Format token count (e.g., 5000 -> "5.0k")
732
+ */
733
+ formatTokens(tokens) {
734
+ if (tokens >= 1000) {
735
+ return `${(tokens / 1000).toFixed(1)}k`;
736
+ }
737
+ return String(tokens);
738
+ }
739
+ /**
740
+ * Toggle todo list visibility (Ctrl+T)
741
+ */
742
+ toggleTodos() {
743
+ this.showTodos = !this.showTodos;
744
+ this.needsRender = true;
745
+ }
746
+ /**
747
+ * Set todo list visibility
748
+ */
749
+ setShowTodos(show) {
750
+ this.showTodos = show;
751
+ this.needsRender = true;
752
+ }
753
+ /**
754
+ * Get todo list visibility
755
+ */
756
+ getShowTodos() {
757
+ return this.showTodos;
758
+ }
759
+ /**
760
+ * Toggle verbose view mode (Ctrl+O)
761
+ * Shows conversation in verbose mode temporarily.
762
+ * Any key returns to normal view.
763
+ */
764
+ toggleVerboseView() {
765
+ if (this.viewMode === 'normal') {
766
+ this.viewMode = 'verbose-temp';
767
+ this.reRenderConversationVerbose(true);
768
+ }
769
+ else {
770
+ this.viewMode = 'normal';
771
+ this.reRenderConversationVerbose(false);
772
+ }
773
+ }
774
+ /**
775
+ * Re-render conversation with temporary verbose setting.
776
+ */
777
+ reRenderConversationVerbose(verbose) {
778
+ const s = getStyles();
779
+ // Clear screen and scrollback buffer
780
+ process.stdout.write('\x1b[2J\x1b[3J\x1b[H');
781
+ // Reset footer state
782
+ this.lastRenderHeight = 0;
783
+ this.cursorLineFromBottom = 0;
784
+ // Show mode indicator at top
785
+ if (verbose) {
786
+ console.log(s.info(`[Verbose View Mode] Press any key to return to normal view`));
787
+ console.log('');
788
+ }
789
+ // Re-render all items with temporary verbose override
790
+ const originalVerbose = this.config.verbose;
791
+ this.config.verbose = verbose;
792
+ for (const item of this.conversationHistory) {
793
+ this.renderItem(item);
794
+ }
795
+ // Restore original config
796
+ this.config.verbose = originalVerbose;
797
+ // Footer will be re-rendered by the render loop
798
+ this.needsRender = true;
799
+ }
800
+ // ===========================================================================
801
+ // Public API - Config
802
+ // ===========================================================================
803
+ /**
804
+ * Update configuration at runtime.
805
+ * If verbose changes, re-renders the entire conversation.
806
+ */
807
+ setConfig(newConfig) {
808
+ const oldVerbose = this.config.verbose;
809
+ this.config = { ...this.config, ...newConfig };
810
+ // If verbose changed, re-render entire conversation
811
+ if (newConfig.verbose !== undefined && newConfig.verbose !== oldVerbose) {
812
+ this.reRenderConversation();
813
+ }
814
+ // Theme change requires prompt refresh
815
+ if (newConfig.theme !== undefined) {
816
+ this.refreshPrompt();
817
+ }
818
+ this.needsRender = true;
819
+ }
820
+ /**
821
+ * Get current configuration.
822
+ */
823
+ getConfig() {
824
+ return { ...this.config };
825
+ }
826
+ /**
827
+ * Re-render entire conversation with current config.
828
+ * Called when verbose mode changes.
829
+ */
830
+ reRenderConversation() {
831
+ // Clear screen AND scrollback buffer, then move to home
832
+ // \x1b[2J - clear entire screen
833
+ // \x1b[3J - clear scrollback buffer (prevents accumulation)
834
+ // \x1b[H - move cursor to home position
835
+ process.stdout.write('\x1b[2J\x1b[3J\x1b[H');
836
+ // Reset footer state since we cleared everything
837
+ this.lastRenderHeight = 0;
838
+ this.cursorLineFromBottom = 0;
839
+ // Re-render all items with new config
840
+ for (const item of this.conversationHistory) {
841
+ this.renderItem(item);
842
+ }
843
+ // Footer will be re-rendered by the render loop
844
+ this.needsRender = true;
845
+ }
846
+ // ===========================================================================
847
+ // Public API - Conversation History
848
+ // ===========================================================================
849
+ /**
850
+ * Clear conversation history.
851
+ * Called by /clear command.
852
+ */
853
+ clearConversationHistory() {
854
+ this.conversationHistory = [];
855
+ }
856
+ /**
857
+ * Get conversation history (copy).
858
+ */
859
+ getConversationHistory() {
860
+ return [...this.conversationHistory];
861
+ }
862
+ // ===========================================================================
863
+ // Public API - Overlays
864
+ // ===========================================================================
865
+ /**
866
+ * Check if an overlay is currently active.
867
+ */
868
+ hasActiveOverlay() {
869
+ return this.overlayStack.length > 0;
870
+ }
871
+ /**
872
+ * Get the currently active overlay (if any).
873
+ */
874
+ getActiveOverlay() {
875
+ return this.overlayStack.length > 0
876
+ ? this.overlayStack[this.overlayStack.length - 1]
877
+ : null;
878
+ }
879
+ /**
880
+ * Show an overlay and wait for result.
881
+ * Returns promise that resolves when overlay closes.
882
+ */
883
+ async showOverlay(overlay) {
884
+ // Check if we're pushing onto an existing overlay (stack has items before this push)
885
+ const isPushingOntoExisting = this.overlayStack.length > 0;
886
+ // 1. If pushing onto existing overlay, clear its render first
887
+ if (isPushingOntoExisting) {
888
+ this.clearOverlayRender();
889
+ }
890
+ // 2. Push overlay onto stack
891
+ this.overlayStack.push(overlay);
892
+ // 3. Call onMount lifecycle (alternate screen overlays enter alternate screen here)
893
+ await overlay.onMount?.();
894
+ // 4. Switch render mode based on overlay type
895
+ // Skip for alternate screen overlays - they manage their own screen buffer
896
+ if (!overlay.usesAlternateScreen) {
897
+ if (overlay.type === 'fullscreen') {
898
+ this.enterFullscreenOverlayMode();
899
+ }
900
+ else {
901
+ this.enterInlineOverlayMode();
902
+ }
903
+ }
904
+ // 5. Initial render
905
+ this.renderOverlay();
906
+ // 6. Wait for close
907
+ return new Promise((resolve) => {
908
+ this.overlayResolvers.set(overlay.id, resolve);
909
+ });
910
+ }
911
+ /**
912
+ * Close current overlay with result.
913
+ * Called internally when overlay returns close action.
914
+ */
915
+ closeCurrentOverlay(result, cancelled) {
916
+ const overlay = this.overlayStack.pop();
917
+ if (!overlay)
918
+ return;
919
+ // Check if overlay uses alternate screen (manages its own screen buffer)
920
+ const usesAlternateScreen = overlay.usesAlternateScreen === true;
921
+ // 1. Call onUnmount lifecycle (this exits alternate screen if used)
922
+ overlay.onUnmount?.();
923
+ // 2. Get close summary before clearing (needs overlay reference)
924
+ const summary = (!cancelled && result !== null)
925
+ ? overlay.getCloseSummary?.(result)
926
+ : null;
927
+ // 3. Restore render mode FIRST (clears overlay from screen)
928
+ if (this.overlayStack.length === 0) {
929
+ // Skip exitOverlayMode for alternate screen overlays - exiting alternate
930
+ // screen already restored the main screen, clearing would corrupt it
931
+ if (usesAlternateScreen) {
932
+ // Just reset state, skip clearing AND skip re-rendering footer
933
+ // (alternate screen restore already put the screen back to its previous state)
934
+ this.overlayRenderState = { lineCount: 0, maxLineCount: 0 };
935
+ this.flushOverlayPrintBuffer();
936
+ // Don't call render() - the footer was restored by alternate screen exit
937
+ }
938
+ else {
939
+ this.exitOverlayMode();
940
+ }
941
+ }
942
+ else {
943
+ // Popping back to parent overlay:
944
+ // 1. Clear the child overlay's render (skip for alternate screen)
945
+ if (!usesAlternateScreen) {
946
+ this.clearOverlayRender();
947
+ }
948
+ // 2. Reset render state for parent
949
+ this.overlayRenderState = { lineCount: 0, maxLineCount: 0 };
950
+ // 3. Re-render parent overlay
951
+ this.renderOverlay();
952
+ }
953
+ // 4. Show close summary AFTER clearing overlay
954
+ if (summary) {
955
+ this.print({ type: 'info', message: summary });
956
+ }
957
+ // 5. Resolve promise
958
+ const resolver = this.overlayResolvers.get(overlay.id);
959
+ resolver?.(result);
960
+ this.overlayResolvers.delete(overlay.id);
961
+ }
962
+ /**
963
+ * Enter fullscreen overlay mode.
964
+ * Clears screen and prepares for overlay rendering.
965
+ */
966
+ enterFullscreenOverlayMode() {
967
+ // Clear footer first
968
+ this.clear();
969
+ // Clear screen for fullscreen overlay
970
+ process.stdout.write('\x1b[2J\x1b[H');
971
+ // Reset overlay render state (both lineCount and maxLineCount)
972
+ this.overlayRenderState = { lineCount: 0, maxLineCount: 0 };
973
+ }
974
+ /**
975
+ * Enter inline overlay mode.
976
+ * Clears footer but keeps conversation visible.
977
+ */
978
+ enterInlineOverlayMode() {
979
+ // Clear footer to make room for inline overlay
980
+ this.clear();
981
+ // Reset overlay render state (both lineCount and maxLineCount)
982
+ this.overlayRenderState = { lineCount: 0, maxLineCount: 0 };
983
+ }
984
+ /**
985
+ * Exit overlay mode and restore normal rendering.
986
+ */
987
+ exitOverlayMode() {
988
+ // Clear overlay content (uses maxLineCount to ensure all lines are cleared)
989
+ this.clearOverlayRender();
990
+ // Reset state
991
+ this.overlayRenderState = { lineCount: 0, maxLineCount: 0 };
992
+ // Flush any items that were buffered while overlay was active
993
+ this.flushOverlayPrintBuffer();
994
+ // Re-render footer
995
+ this.render();
996
+ }
997
+ /**
998
+ * Flush items that were buffered while an overlay was active.
999
+ * Called after overlay closes to render any pending output.
1000
+ */
1001
+ flushOverlayPrintBuffer() {
1002
+ if (this.overlayPrintBuffer.length === 0)
1003
+ return;
1004
+ // Render all buffered items
1005
+ for (const item of this.overlayPrintBuffer) {
1006
+ this.renderItem(item);
1007
+ }
1008
+ // Clear the buffer
1009
+ this.overlayPrintBuffer = [];
1010
+ }
1011
+ /**
1012
+ * Clear previous overlay render.
1013
+ * Uses maxLineCount to ensure all lines are cleared even if content shrank between renders.
1014
+ */
1015
+ clearOverlayRender() {
1016
+ // Use maxLineCount to handle cases where overlay content varied between renders
1017
+ const linesToClear = this.overlayRenderState.maxLineCount;
1018
+ if (linesToClear > 0) {
1019
+ // Move cursor up to start of overlay
1020
+ process.stdout.write(`\x1b[${String(linesToClear)}A`);
1021
+ // Move to column 1
1022
+ process.stdout.write('\r');
1023
+ // Clear from cursor to end of screen
1024
+ process.stdout.write('\x1b[J');
1025
+ }
1026
+ }
1027
+ /**
1028
+ * Render the active overlay.
1029
+ */
1030
+ renderOverlay() {
1031
+ const overlay = this.getActiveOverlay();
1032
+ if (!overlay)
1033
+ return;
1034
+ const termWidth = terminal.getTerminalWidth();
1035
+ const termHeight = terminal.getTerminalHeight();
1036
+ const s = getStyles();
1037
+ const context = {
1038
+ width: termWidth,
1039
+ height: termHeight,
1040
+ styles: s,
1041
+ };
1042
+ const content = overlay.render(context);
1043
+ if (overlay.type === 'fullscreen') {
1044
+ this.renderFullscreenOverlay(content, termWidth);
1045
+ }
1046
+ else {
1047
+ this.renderInlineOverlay(content, termWidth);
1048
+ }
1049
+ }
1050
+ /**
1051
+ * Render fullscreen overlay.
1052
+ *
1053
+ * CRITICAL: Cursor positioning must be consistent between renders.
1054
+ * Same logic as renderInlineOverlay - pad to maxLineCount for consistency.
1055
+ */
1056
+ renderFullscreenOverlay(content, termWidth) {
1057
+ // Clear previous render
1058
+ this.clearOverlayRender();
1059
+ // Calculate physical lines
1060
+ let physicalLines = 0;
1061
+ for (const line of content.lines) {
1062
+ const visibleLen = getVisibleLength(line);
1063
+ physicalLines += Math.max(1, Math.ceil(visibleLen / termWidth));
1064
+ }
1065
+ // Update maxLineCount BEFORE padding (track the natural maximum)
1066
+ const contentMinHeight = content.minHeight ?? 0;
1067
+ const naturalHeight = Math.max(physicalLines, contentMinHeight);
1068
+ this.overlayRenderState.maxLineCount = Math.max(this.overlayRenderState.maxLineCount, naturalHeight);
1069
+ // CRITICAL: Pad to maxLineCount for consistent cursor positioning
1070
+ const targetHeight = this.overlayRenderState.maxLineCount;
1071
+ const paddedLines = [...content.lines];
1072
+ while (physicalLines < targetHeight) {
1073
+ paddedLines.push('');
1074
+ physicalLines++;
1075
+ }
1076
+ // Update lineCount to actual rendered height
1077
+ this.overlayRenderState.lineCount = physicalLines;
1078
+ // Render lines
1079
+ for (let i = 0; i < paddedLines.length; i++) {
1080
+ process.stdout.write(paddedLines[i]);
1081
+ if (i < paddedLines.length - 1) {
1082
+ process.stdout.write('\n');
1083
+ }
1084
+ }
1085
+ // Position cursor if specified
1086
+ if (content.cursorPosition) {
1087
+ const { line, column } = content.cursorPosition;
1088
+ // Move to the correct line (relative from end)
1089
+ const linesFromEnd = paddedLines.length - 1 - line;
1090
+ if (linesFromEnd > 0) {
1091
+ process.stdout.write(`\x1b[${String(linesFromEnd)}A`);
1092
+ }
1093
+ process.stdout.write(`\x1b[${String(column)}G`);
1094
+ }
1095
+ terminal.showCursor();
1096
+ }
1097
+ /**
1098
+ * Render inline overlay (above footer).
1099
+ *
1100
+ * CRITICAL: Cursor positioning must be consistent between renders.
1101
+ * After rendering, cursor MUST be at maxLineCount position, not at
1102
+ * actual content height. Otherwise, the next clear will move cursor
1103
+ * up by maxLineCount from the wrong position, causing "ghost lines".
1104
+ */
1105
+ renderInlineOverlay(content, termWidth) {
1106
+ // Clear previous render
1107
+ this.clearOverlayRender();
1108
+ // Calculate physical lines
1109
+ let physicalLines = 0;
1110
+ for (const line of content.lines) {
1111
+ const visibleLen = getVisibleLength(line);
1112
+ physicalLines += Math.max(1, Math.ceil(visibleLen / termWidth));
1113
+ }
1114
+ // Update maxLineCount BEFORE padding (track the natural maximum)
1115
+ const contentMinHeight = content.minHeight ?? 0;
1116
+ const naturalHeight = Math.max(physicalLines, contentMinHeight);
1117
+ this.overlayRenderState.maxLineCount = Math.max(this.overlayRenderState.maxLineCount, naturalHeight);
1118
+ // CRITICAL: Pad to maxLineCount, not just minHeight!
1119
+ // This ensures cursor position is consistent between renders.
1120
+ // Without this, going from tall content (e.g., detail screen with 25 lines)
1121
+ // to short content (e.g., main list with 20 lines) leaves cursor at line 20,
1122
+ // but next clear moves up 25 lines → cursor goes to line -5 → ghost lines!
1123
+ const targetHeight = this.overlayRenderState.maxLineCount;
1124
+ const paddedLines = [...content.lines];
1125
+ while (physicalLines < targetHeight) {
1126
+ paddedLines.push('');
1127
+ physicalLines++;
1128
+ }
1129
+ // Update lineCount to actual rendered height
1130
+ this.overlayRenderState.lineCount = physicalLines;
1131
+ // Render lines (each line ends with \n for consistent cursor positioning)
1132
+ for (const line of paddedLines) {
1133
+ process.stdout.write(line + '\n');
1134
+ }
1135
+ terminal.showCursor();
1136
+ }
1137
+ /**
1138
+ * Process overlay action returned by handleKey.
1139
+ */
1140
+ processOverlayAction(action) {
1141
+ switch (action.type) {
1142
+ case 'none':
1143
+ // Do nothing
1144
+ break;
1145
+ case 'render':
1146
+ this.renderOverlay();
1147
+ break;
1148
+ case 'close':
1149
+ if (action.cancelled) {
1150
+ this.closeCurrentOverlay(null, true);
1151
+ }
1152
+ else {
1153
+ this.closeCurrentOverlay(action.result ?? null, false);
1154
+ }
1155
+ break;
1156
+ case 'push':
1157
+ if (action.overlay) {
1158
+ void this.showOverlay(action.overlay);
1159
+ }
1160
+ break;
1161
+ case 'pop':
1162
+ this.closeCurrentOverlay(null, true);
1163
+ break;
1164
+ }
1165
+ }
1166
+ // ===========================================================================
1167
+ // Public API - Input queue
1168
+ // ===========================================================================
1169
+ getQueuedInputs() {
1170
+ return [...this.queuedInputs];
1171
+ }
1172
+ popQueuedInput() {
1173
+ if (this.queuedInputs.length === 0)
1174
+ return null;
1175
+ const input = this.queuedInputs.shift();
1176
+ this.needsRender = true;
1177
+ return input ?? null;
1178
+ }
1179
+ hasQueuedInput() {
1180
+ return this.queuedInputs.length > 0;
1181
+ }
1182
+ clearQueue() {
1183
+ this.queuedInputs = [];
1184
+ this.needsRender = true;
1185
+ }
1186
+ // ===========================================================================
1187
+ // Public API - Agent message queue
1188
+ // ===========================================================================
1189
+ /**
1190
+ * Queue a message to be sent to the agent.
1191
+ * Used by commands that need to invoke the agent programmatically.
1192
+ */
1193
+ queueAgentMessage(options) {
1194
+ this.agentMessageQueue.push(options);
1195
+ }
1196
+ /**
1197
+ * Pop the next agent message from the queue.
1198
+ */
1199
+ popAgentMessage() {
1200
+ return this.agentMessageQueue.shift() ?? null;
1201
+ }
1202
+ /**
1203
+ * Check if there are queued agent messages.
1204
+ */
1205
+ hasAgentMessage() {
1206
+ return this.agentMessageQueue.length > 0;
1207
+ }
1208
+ /**
1209
+ * Clear all queued agent messages.
1210
+ */
1211
+ clearAgentMessageQueue() {
1212
+ this.agentMessageQueue = [];
1213
+ }
1214
+ // ===========================================================================
1215
+ // Public API - Animation control (for overlays)
1216
+ // ===========================================================================
1217
+ /**
1218
+ * Pause footer completely (for overlays)
1219
+ */
1220
+ pauseAnimation() {
1221
+ this.isPaused = true;
1222
+ // Stop render loop
1223
+ if (this.renderTimer) {
1224
+ clearInterval(this.renderTimer);
1225
+ this.renderTimer = null;
1226
+ }
1227
+ // Stop spinner
1228
+ this.stopSpinnerAnimation();
1229
+ // Stop keyboard capture
1230
+ this.stopKeyboardCapture();
1231
+ // Clear footer from screen
1232
+ this.clear();
1233
+ }
1234
+ /**
1235
+ * Resume footer after pause
1236
+ */
1237
+ resumeAnimation() {
1238
+ this.isPaused = false;
1239
+ // Resume spinner if agent running
1240
+ if (this.agentRunning) {
1241
+ this.startSpinnerAnimation();
1242
+ }
1243
+ // Restart keyboard capture
1244
+ this.startKeyboardCapture();
1245
+ // Restart render loop
1246
+ this.render();
1247
+ this.renderTimer = setInterval(() => {
1248
+ if (this.needsRender && !this.isPaused) {
1249
+ this.render();
1250
+ this.needsRender = false;
1251
+ }
1252
+ }, 60);
1253
+ }
1254
+ /**
1255
+ * Restart input after command
1256
+ */
1257
+ restartInput() {
1258
+ this.render();
1259
+ }
1260
+ /**
1261
+ * Refresh prompt with theme colors
1262
+ */
1263
+ refreshPrompt() {
1264
+ const s = getStyles();
1265
+ this.promptPrefix = s.primaryBold('compilr>') + ' ';
1266
+ this.promptPrefixLen = getVisibleLength(this.promptPrefix);
1267
+ this.needsRender = true;
1268
+ }
1269
+ // ===========================================================================
1270
+ // Public API - Legacy compatibility (for gradual migration)
1271
+ // ===========================================================================
1272
+ /**
1273
+ * @deprecated Use print() or output() instead
1274
+ */
1275
+ clearForOutput() {
1276
+ this.clear();
1277
+ }
1278
+ /**
1279
+ * @deprecated Use print() or output() instead
1280
+ */
1281
+ forceRender() {
1282
+ this.render();
1283
+ this.needsRender = false;
1284
+ }
1285
+ // ===========================================================================
1286
+ // Private - Rendering
1287
+ // ===========================================================================
1288
+ clear() {
1289
+ if (this.lastRenderHeight === 0) {
1290
+ return;
1291
+ }
1292
+ terminal.moveCursorToLineStart();
1293
+ // Move cursor to TOP of footer
1294
+ // cursorRowFromTop = which row the cursor is on (1-indexed from top of footer)
1295
+ const cursorRowFromTop = this.lastRenderHeight - this.cursorLineFromBottom;
1296
+ const rowsToMoveUp = cursorRowFromTop - 1;
1297
+ if (rowsToMoveUp > 0) {
1298
+ terminal.moveCursorUp(rowsToMoveUp);
1299
+ }
1300
+ terminal.clearToEndOfScreen();
1301
+ this.lastRenderHeight = 0;
1302
+ this.cursorLineFromBottom = 0;
1303
+ }
1304
+ render() {
1305
+ if (!this.isRunning || this.isPaused) {
1306
+ return;
1307
+ }
1308
+ // Don't render footer when overlay is active
1309
+ if (this.hasActiveOverlay()) {
1310
+ return;
1311
+ }
1312
+ const termWidth = terminal.getTerminalWidth();
1313
+ const s = getStyles();
1314
+ // Build all lines in order
1315
+ const lines = [];
1316
+ // 0. LiveRegion (if has items) - rendered at top of footer
1317
+ if (this.liveRegion.hasItems()) {
1318
+ const liveLines = this.liveRegion.render({
1319
+ width: termWidth,
1320
+ verbose: this.config.verbose,
1321
+ showTokens: true,
1322
+ });
1323
+ for (const line of liveLines) {
1324
+ lines.push({
1325
+ content: line,
1326
+ physicalLines: getPhysicalLineCount(line, termWidth),
1327
+ });
1328
+ }
1329
+ // Blank line after live region for spacing
1330
+ if (liveLines.length > 0) {
1331
+ lines.push({ content: '', physicalLines: 1 });
1332
+ }
1333
+ }
1334
+ // 1. Header: Spinner (when running) or "Todos" (when idle with todos)
1335
+ if (this.agentRunning) {
1336
+ const spinnerLine = this.buildSpinnerLine();
1337
+ lines.push({
1338
+ content: spinnerLine,
1339
+ physicalLines: getPhysicalLineCount(spinnerLine, termWidth),
1340
+ });
1341
+ }
1342
+ else if (this.todos.length > 0) {
1343
+ const headerLine = s.muted('Todos');
1344
+ lines.push({
1345
+ content: headerLine,
1346
+ physicalLines: 1,
1347
+ });
1348
+ }
1349
+ // 2. Todo list (show if there are todos AND showTodos is true)
1350
+ if (this.todos.length > 0 && this.showTodos) {
1351
+ for (const todo of this.todos) {
1352
+ const todoLine = this.buildTodoLine(todo);
1353
+ lines.push({
1354
+ content: todoLine,
1355
+ physicalLines: getPhysicalLineCount(todoLine, termWidth),
1356
+ });
1357
+ }
1358
+ // Blank line after todos for spacing
1359
+ lines.push({ content: '', physicalLines: 1 });
1360
+ }
1361
+ // 3. Queued inputs (show multiline as single line with ↵ indicator)
1362
+ for (const queued of this.queuedInputs) {
1363
+ const displayText = queued.replace(/\n/g, ' ↵ ');
1364
+ const queuedLine = s.muted(`queued: "${displayText}"`);
1365
+ lines.push({
1366
+ content: queuedLine,
1367
+ physicalLines: getPhysicalLineCount(queuedLine, termWidth),
1368
+ });
1369
+ }
1370
+ // 4. Top separator
1371
+ const separator = '─'.repeat(termWidth);
1372
+ lines.push({ content: separator, physicalLines: 1 });
1373
+ // 5. Input prompt (multiline support)
1374
+ const continuationPrompt = s.muted(' \\ ');
1375
+ const continuationPromptLen = getVisibleLength(continuationPrompt);
1376
+ const cursorLineIndex = lines.length + this.currentLine; // Index where cursor is
1377
+ for (let i = 0; i < this.lines.length; i++) {
1378
+ const linePrompt = i === 0 ? this.promptPrefix : continuationPrompt;
1379
+ const linePromptLen = i === 0 ? this.promptPrefixLen : continuationPromptLen;
1380
+ const lineContent = this.lines[i];
1381
+ // Show ghost text on first line when input is empty and no autocomplete
1382
+ let fullLine;
1383
+ let totalLen;
1384
+ if (i === 0 &&
1385
+ lineContent === '' &&
1386
+ this.lines.length === 1 &&
1387
+ this.suggestion &&
1388
+ !this.autocomplete.active &&
1389
+ !this.fileAutocomplete.active) {
1390
+ // Build: [prompt][suggestion]...[↵ send]
1391
+ const rightHint = '↵ send';
1392
+ const leftContent = linePrompt + s.muted(this.suggestion);
1393
+ const leftLen = linePromptLen + this.suggestion.length;
1394
+ const rightLen = rightHint.length;
1395
+ const padding = Math.max(1, termWidth - leftLen - rightLen);
1396
+ fullLine = leftContent + ' '.repeat(padding) + s.muted(rightHint);
1397
+ totalLen = termWidth; // Full width
1398
+ }
1399
+ else {
1400
+ fullLine = linePrompt + lineContent;
1401
+ totalLen = linePromptLen + getVisibleLength(lineContent);
1402
+ }
1403
+ const physicalLines = totalLen === 0 ? 1 : Math.ceil(totalLen / termWidth) || 1;
1404
+ lines.push({
1405
+ content: fullLine,
1406
+ physicalLines,
1407
+ });
1408
+ }
1409
+ // 6. Bottom separator
1410
+ lines.push({ content: separator, physicalLines: 1 });
1411
+ // 7. Mode indicator
1412
+ const modeLine = this.buildModeLine(termWidth);
1413
+ lines.push({
1414
+ content: modeLine,
1415
+ physicalLines: getPhysicalLineCount(modeLine, termWidth),
1416
+ });
1417
+ // 8. Autocomplete dropdown (if active) - file autocomplete takes priority
1418
+ const fileAutocompleteLines = this.buildFileAutocompleteLines(termWidth);
1419
+ const autocompleteLines = fileAutocompleteLines.length > 0
1420
+ ? fileAutocompleteLines
1421
+ : this.buildAutocompleteLines(termWidth);
1422
+ for (const line of autocompleteLines) {
1423
+ lines.push({
1424
+ content: line,
1425
+ physicalLines: getPhysicalLineCount(line, termWidth),
1426
+ });
1427
+ }
1428
+ // Calculate total physical height
1429
+ let totalPhysicalLines = 0;
1430
+ for (const line of lines) {
1431
+ totalPhysicalLines += line.physicalLines;
1432
+ }
1433
+ // Clear existing footer
1434
+ this.clear();
1435
+ // Write all lines
1436
+ for (let i = 0; i < lines.length; i++) {
1437
+ terminal.write(lines[i].content);
1438
+ if (i < lines.length - 1) {
1439
+ terminal.write('\n');
1440
+ }
1441
+ }
1442
+ // Calculate cursor position (for multiline, position on the line where cursor is)
1443
+ let rowsAfterCursor = 0;
1444
+ for (let i = cursorLineIndex + 1; i < lines.length; i++) {
1445
+ rowsAfterCursor += lines[i].physicalLines;
1446
+ }
1447
+ if (rowsAfterCursor > 0) {
1448
+ terminal.moveCursorUp(rowsAfterCursor);
1449
+ }
1450
+ // Calculate column within current line
1451
+ const currentLinePromptLen = this.currentLine === 0 ? this.promptPrefixLen : continuationPromptLen;
1452
+ const totalPos = currentLinePromptLen + this.cursorPos;
1453
+ const cursorCol = (totalPos % termWidth) + 1;
1454
+ terminal.moveCursorToColumn(cursorCol);
1455
+ terminal.showCursor();
1456
+ this.lastRenderHeight = totalPhysicalLines;
1457
+ this.cursorLineFromBottom = rowsAfterCursor;
1458
+ }
1459
+ // ===========================================================================
1460
+ // Private - Line builders
1461
+ // ===========================================================================
1462
+ buildSpinnerLine() {
1463
+ const s = getStyles();
1464
+ const frame = this.spinnerFrames[this.spinnerFrame % this.spinnerFrames.length];
1465
+ const text = this.currentTool ?? this.spinnerText ?? 'Thinking...';
1466
+ // Build hints
1467
+ const hints = ['esc to interrupt'];
1468
+ if (this.todos.length > 0 && this.showTodos) {
1469
+ hints.push('ctrl+t to hide todos');
1470
+ }
1471
+ else if (this.todos.length > 0 && !this.showTodos) {
1472
+ hints.push('ctrl+t to show todos');
1473
+ }
1474
+ const hintsText = hints.length > 0 ? ` (${hints.join(' · ')})` : '';
1475
+ // Format: [CRT scanner] text (hints)
1476
+ return s.muted(`${frame} ${text}${hintsText}`);
1477
+ }
1478
+ buildTodoLine(todo) {
1479
+ const s = getStyles();
1480
+ const icon = todo.status === 'completed' ? '✓' :
1481
+ todo.status === 'in_progress' ? '→' : '☐';
1482
+ const style = todo.status === 'completed' ? s.muted :
1483
+ todo.status === 'in_progress' ? s.info : s.muted;
1484
+ return style(`${icon} ${todo.content}`);
1485
+ }
1486
+ buildModeLine(termWidth) {
1487
+ const s = getStyles();
1488
+ const modeInfo = MODE_INFO[this.mode];
1489
+ let leftPart;
1490
+ switch (this.mode) {
1491
+ case 'normal':
1492
+ leftPart = s.muted(`mode: ${modeInfo.label} (Shift+Tab to change)`);
1493
+ break;
1494
+ case 'auto-accept':
1495
+ leftPart = s.warning(`mode: ${modeInfo.label}`) + s.muted(' (Shift+Tab to change)');
1496
+ break;
1497
+ case 'plan':
1498
+ leftPart = s.info(`mode: ${modeInfo.label}`) + s.muted(' (Shift+Tab to change)');
1499
+ break;
1500
+ default:
1501
+ leftPart = s.muted(`mode: ${modeInfo.label} (Shift+Tab to change)`);
1502
+ }
1503
+ if (!this.projectName) {
1504
+ return leftPart;
1505
+ }
1506
+ const projectText = `Project: ${this.projectName}`;
1507
+ const rightPart = s.muted(projectText);
1508
+ const leftVisible = getVisibleLength(leftPart);
1509
+ const padding = Math.max(2, termWidth - leftVisible - projectText.length);
1510
+ return leftPart + ' '.repeat(padding) + rightPart;
1511
+ }
1512
+ buildAutocompleteLines(termWidth) {
1513
+ if (!this.autocomplete.active || this.autocomplete.matches.length === 0) {
1514
+ return [];
1515
+ }
1516
+ const s = getStyles();
1517
+ const lines = [];
1518
+ const { matches, selectedIndex, scrollOffset } = this.autocomplete;
1519
+ const visible = matches.slice(scrollOffset, scrollOffset + MAX_VISIBLE_COMMANDS);
1520
+ const total = matches.length;
1521
+ // Show scroll indicator if there are more items above
1522
+ if (scrollOffset > 0) {
1523
+ lines.push(s.muted(` ↑ ${String(scrollOffset)} more above`));
1524
+ }
1525
+ for (let i = 0; i < visible.length; i++) {
1526
+ const cmd = visible[i];
1527
+ const actualIndex = scrollOffset + i;
1528
+ const isSelected = actualIndex === selectedIndex;
1529
+ const prefix = isSelected ? s.primary('❯ ') : ' ';
1530
+ const name = isSelected ? s.primaryBold(cmd.command) : cmd.command;
1531
+ // Truncate if too long
1532
+ const baseLen = getVisibleLength(prefix) + getVisibleLength(name) + 3; // 3 for ' - '
1533
+ const maxDescLen = termWidth - baseLen - 2;
1534
+ const truncatedDesc = cmd.description.length > maxDescLen
1535
+ ? cmd.description.slice(0, maxDescLen - 1) + '…'
1536
+ : cmd.description;
1537
+ lines.push(`${prefix}${name} ${s.muted('- ' + truncatedDesc)}`);
1538
+ }
1539
+ // Show scroll indicator if there are more items below
1540
+ const belowCount = total - scrollOffset - visible.length;
1541
+ if (belowCount > 0) {
1542
+ lines.push(s.muted(` ↓ ${String(belowCount)} more below`));
1543
+ }
1544
+ return lines;
1545
+ }
1546
+ buildFileAutocompleteLines(termWidth) {
1547
+ if (!this.fileAutocomplete.active || this.fileAutocomplete.matches.length === 0) {
1548
+ return [];
1549
+ }
1550
+ const s = getStyles();
1551
+ const lines = [];
1552
+ const { matches, selectedIndex, scrollOffset } = this.fileAutocomplete;
1553
+ const visible = matches.slice(scrollOffset, scrollOffset + MAX_VISIBLE_COMMANDS);
1554
+ const total = matches.length;
1555
+ // Show scroll indicator if there are more items above
1556
+ if (scrollOffset > 0) {
1557
+ lines.push(s.muted(` ↑ ${String(scrollOffset)} more above`));
1558
+ }
1559
+ for (let i = 0; i < visible.length; i++) {
1560
+ const file = visible[i];
1561
+ const actualIndex = scrollOffset + i;
1562
+ const isSelected = actualIndex === selectedIndex;
1563
+ const prefix = isSelected ? s.primary('❯ ') : ' ';
1564
+ const icon = file.isDirectory ? '📁 ' : '📄 ';
1565
+ const name = isSelected ? s.primaryBold(file.path) : file.path;
1566
+ // Truncate if too long
1567
+ const baseLen = getVisibleLength(prefix) + 3 + getVisibleLength(file.path); // 3 for icon
1568
+ if (baseLen > termWidth - 2) {
1569
+ const maxLen = termWidth - getVisibleLength(prefix) - 5; // 3 for icon, 2 for padding
1570
+ const truncatedPath = file.path.length > maxLen
1571
+ ? '…' + file.path.slice(-(maxLen - 1))
1572
+ : file.path;
1573
+ const truncatedName = isSelected ? s.primaryBold(truncatedPath) : truncatedPath;
1574
+ lines.push(`${prefix}${icon}${truncatedName}`);
1575
+ }
1576
+ else {
1577
+ lines.push(`${prefix}${icon}${name}`);
1578
+ }
1579
+ }
1580
+ // Show scroll indicator if there are more items below
1581
+ const belowCount = total - scrollOffset - visible.length;
1582
+ if (belowCount > 0) {
1583
+ lines.push(s.muted(` ↓ ${String(belowCount)} more below`));
1584
+ }
1585
+ return lines;
1586
+ }
1587
+ // ===========================================================================
1588
+ // Private - Cursor calculations
1589
+ // ===========================================================================
1590
+ // ===========================================================================
1591
+ // Private - Autocomplete
1592
+ // ===========================================================================
1593
+ updateAutocomplete() {
1594
+ const currentLine = this.getCurrentLineContent();
1595
+ // Check for @ file path autocomplete first (works on any line)
1596
+ const atMention = extractAtMention(currentLine, this.cursorPos);
1597
+ if (atMention !== null) {
1598
+ // File autocomplete mode - disable command autocomplete
1599
+ this.autocomplete.active = false;
1600
+ this.autocomplete.matches = [];
1601
+ this.autocomplete.selectedIndex = 0;
1602
+ this.autocomplete.scrollOffset = 0;
1603
+ // Enable file autocomplete
1604
+ this.fileAutocomplete.active = true;
1605
+ this.fileAutocomplete.partial = atMention;
1606
+ this.fileAutocomplete.matches = getFileMatches(atMention);
1607
+ if (this.fileAutocomplete.selectedIndex >= this.fileAutocomplete.matches.length) {
1608
+ this.fileAutocomplete.selectedIndex = Math.max(0, this.fileAutocomplete.matches.length - 1);
1609
+ this.fileAutocomplete.scrollOffset = 0;
1610
+ }
1611
+ return;
1612
+ }
1613
+ // Reset file autocomplete
1614
+ this.fileAutocomplete.active = false;
1615
+ this.fileAutocomplete.matches = [];
1616
+ this.fileAutocomplete.selectedIndex = 0;
1617
+ this.fileAutocomplete.scrollOffset = 0;
1618
+ this.fileAutocomplete.partial = '';
1619
+ // Activate command autocomplete when first line starts with /
1620
+ const firstLine = this.lines[0];
1621
+ if (this.currentLine === 0 && firstLine.startsWith('/')) {
1622
+ this.autocomplete.active = true;
1623
+ const freshCommands = getAutocompleteCommands();
1624
+ this.autocomplete.matches = filterCommands(firstLine, freshCommands);
1625
+ // Reset selection if it's out of bounds
1626
+ if (this.autocomplete.selectedIndex >= this.autocomplete.matches.length) {
1627
+ this.autocomplete.selectedIndex = Math.max(0, this.autocomplete.matches.length - 1);
1628
+ this.autocomplete.scrollOffset = 0;
1629
+ }
1630
+ }
1631
+ else {
1632
+ this.autocomplete.active = false;
1633
+ this.autocomplete.matches = [];
1634
+ this.autocomplete.selectedIndex = 0;
1635
+ this.autocomplete.scrollOffset = 0;
1636
+ }
1637
+ }
1638
+ acceptAutocomplete() {
1639
+ // File autocomplete takes priority (works on current line)
1640
+ if (this.fileAutocomplete.active && this.fileAutocomplete.matches.length > 0) {
1641
+ const selectedFile = this.fileAutocomplete.matches[this.fileAutocomplete.selectedIndex];
1642
+ const currentLine = this.getCurrentLineContent();
1643
+ const result = replaceAtMention(currentLine, this.cursorPos, selectedFile.path);
1644
+ this.lines[this.currentLine] = result.input;
1645
+ this.cursorPos = result.cursorPos;
1646
+ this.fileAutocomplete.active = false;
1647
+ this.fileAutocomplete.matches = [];
1648
+ this.fileAutocomplete.selectedIndex = 0;
1649
+ this.fileAutocomplete.scrollOffset = 0;
1650
+ this.fileAutocomplete.partial = '';
1651
+ // Check if still in @ context (e.g., directory selected, might want to continue)
1652
+ this.updateAutocomplete();
1653
+ return;
1654
+ }
1655
+ // Command autocomplete (works on first line only)
1656
+ if (this.autocomplete.active && this.autocomplete.matches.length > 0) {
1657
+ const selected = this.autocomplete.matches[this.autocomplete.selectedIndex];
1658
+ this.lines[0] = selected.command;
1659
+ this.currentLine = 0;
1660
+ this.cursorPos = this.lines[0].length;
1661
+ this.autocomplete.active = false;
1662
+ this.autocomplete.matches = [];
1663
+ this.autocomplete.selectedIndex = 0;
1664
+ this.autocomplete.scrollOffset = 0;
1665
+ }
1666
+ }
1667
+ closeAutocomplete() {
1668
+ // Close file autocomplete
1669
+ this.fileAutocomplete.active = false;
1670
+ this.fileAutocomplete.matches = [];
1671
+ this.fileAutocomplete.selectedIndex = 0;
1672
+ this.fileAutocomplete.scrollOffset = 0;
1673
+ this.fileAutocomplete.partial = '';
1674
+ // Close command autocomplete
1675
+ this.autocomplete.active = false;
1676
+ this.autocomplete.matches = [];
1677
+ this.autocomplete.selectedIndex = 0;
1678
+ this.autocomplete.scrollOffset = 0;
1679
+ }
1680
+ navigateAutocompleteUp() {
1681
+ // File autocomplete navigation takes priority
1682
+ if (this.fileAutocomplete.active && this.fileAutocomplete.matches.length > 0) {
1683
+ if (this.fileAutocomplete.selectedIndex > 0) {
1684
+ this.fileAutocomplete.selectedIndex--;
1685
+ if (this.fileAutocomplete.selectedIndex < this.fileAutocomplete.scrollOffset) {
1686
+ this.fileAutocomplete.scrollOffset = this.fileAutocomplete.selectedIndex;
1687
+ }
1688
+ return true;
1689
+ }
1690
+ return false;
1691
+ }
1692
+ // Command autocomplete navigation
1693
+ if (!this.autocomplete.active || this.autocomplete.matches.length === 0) {
1694
+ return false;
1695
+ }
1696
+ if (this.autocomplete.selectedIndex > 0) {
1697
+ this.autocomplete.selectedIndex--;
1698
+ // Adjust scroll if selection goes above visible area
1699
+ if (this.autocomplete.selectedIndex < this.autocomplete.scrollOffset) {
1700
+ this.autocomplete.scrollOffset = this.autocomplete.selectedIndex;
1701
+ }
1702
+ return true;
1703
+ }
1704
+ return false;
1705
+ }
1706
+ navigateAutocompleteDown() {
1707
+ // File autocomplete navigation takes priority
1708
+ if (this.fileAutocomplete.active && this.fileAutocomplete.matches.length > 0) {
1709
+ if (this.fileAutocomplete.selectedIndex < this.fileAutocomplete.matches.length - 1) {
1710
+ this.fileAutocomplete.selectedIndex++;
1711
+ const maxVisibleIndex = this.fileAutocomplete.scrollOffset + MAX_VISIBLE_COMMANDS - 1;
1712
+ if (this.fileAutocomplete.selectedIndex > maxVisibleIndex) {
1713
+ this.fileAutocomplete.scrollOffset = this.fileAutocomplete.selectedIndex - MAX_VISIBLE_COMMANDS + 1;
1714
+ }
1715
+ return true;
1716
+ }
1717
+ return false;
1718
+ }
1719
+ // Command autocomplete navigation
1720
+ if (!this.autocomplete.active || this.autocomplete.matches.length === 0) {
1721
+ return false;
1722
+ }
1723
+ if (this.autocomplete.selectedIndex < this.autocomplete.matches.length - 1) {
1724
+ this.autocomplete.selectedIndex++;
1725
+ // Adjust scroll if selection goes below visible area
1726
+ const maxVisibleIndex = this.autocomplete.scrollOffset + MAX_VISIBLE_COMMANDS - 1;
1727
+ if (this.autocomplete.selectedIndex > maxVisibleIndex) {
1728
+ this.autocomplete.scrollOffset = this.autocomplete.selectedIndex - MAX_VISIBLE_COMMANDS + 1;
1729
+ }
1730
+ return true;
1731
+ }
1732
+ return false;
1733
+ }
1734
+ // ===========================================================================
1735
+ // Private - History
1736
+ // ===========================================================================
1737
+ addToHistory(input) {
1738
+ const trimmed = input.trim();
1739
+ // Don't add empty or duplicate consecutive entries
1740
+ if (trimmed && (this.history.length === 0 || this.history[this.history.length - 1] !== trimmed)) {
1741
+ this.history.push(trimmed);
1742
+ }
1743
+ }
1744
+ navigateHistoryUp() {
1745
+ if (this.autocomplete.active)
1746
+ return false;
1747
+ if (this.history.length === 0)
1748
+ return false;
1749
+ // Save current input when starting to navigate
1750
+ if (this.historyIndex === -1) {
1751
+ this.savedInput = this.getInputValue();
1752
+ }
1753
+ // Navigate to older entry
1754
+ if (this.historyIndex < this.history.length - 1) {
1755
+ this.historyIndex++;
1756
+ const historyEntry = this.history[this.history.length - 1 - this.historyIndex];
1757
+ // History entries are single-line (newlines stripped on save)
1758
+ this.lines = [historyEntry];
1759
+ this.currentLine = 0;
1760
+ this.cursorPos = historyEntry.length;
1761
+ return true;
1762
+ }
1763
+ return false;
1764
+ }
1765
+ navigateHistoryDown() {
1766
+ if (this.autocomplete.active)
1767
+ return false;
1768
+ if (this.historyIndex < 0)
1769
+ return false;
1770
+ this.historyIndex--;
1771
+ if (this.historyIndex === -1) {
1772
+ // Restore saved input (may be multiline)
1773
+ this.lines = this.savedInput.split('\n');
1774
+ if (this.lines.length === 0)
1775
+ this.lines = [''];
1776
+ this.currentLine = this.lines.length - 1;
1777
+ this.cursorPos = this.lines[this.currentLine].length;
1778
+ }
1779
+ else {
1780
+ // Navigate to newer entry
1781
+ const historyEntry = this.history[this.history.length - 1 - this.historyIndex];
1782
+ this.lines = [historyEntry];
1783
+ this.currentLine = 0;
1784
+ this.cursorPos = historyEntry.length;
1785
+ }
1786
+ return true;
1787
+ }
1788
+ resetHistoryNavigation() {
1789
+ this.historyIndex = -1;
1790
+ this.savedInput = '';
1791
+ }
1792
+ // ===========================================================================
1793
+ // Private - Spinner animation
1794
+ // ===========================================================================
1795
+ startSpinnerAnimation() {
1796
+ if (this.spinnerTimer)
1797
+ return;
1798
+ this.spinnerTimer = setInterval(() => {
1799
+ this.spinnerFrame++;
1800
+ this.needsRender = true;
1801
+ }, 200); // Slower, more relaxed animation
1802
+ }
1803
+ stopSpinnerAnimation() {
1804
+ if (this.spinnerTimer) {
1805
+ clearInterval(this.spinnerTimer);
1806
+ this.spinnerTimer = null;
1807
+ }
1808
+ this.spinnerFrame = 0;
1809
+ }
1810
+ // ===========================================================================
1811
+ // Private - Keyboard handling
1812
+ // ===========================================================================
1813
+ keyHandler = null;
1814
+ dataHandler = null;
1815
+ startKeyboardCapture() {
1816
+ if (this.keyHandler)
1817
+ return; // Already capturing
1818
+ readline.emitKeypressEvents(process.stdin);
1819
+ if (process.stdin.isTTY) {
1820
+ process.stdin.setRawMode(true);
1821
+ }
1822
+ // Handle raw data for reliable Escape and Option+Arrow detection
1823
+ this.dataHandler = (data) => {
1824
+ if (!this.isRunning || this.isPaused)
1825
+ return;
1826
+ // Pure Escape key is a single byte 0x1B
1827
+ if (data.length === 1 && data[0] === 0x1b) {
1828
+ this.handleEscape();
1829
+ return;
1830
+ }
1831
+ // Mac Option+Left (word left): ESC b or ESC [ 1 ; 9 D
1832
+ const isOptionLeft = (data.length === 2 && data[0] === 0x1b && data[1] === 0x62) ||
1833
+ (data.length === 6 &&
1834
+ data[0] === 0x1b &&
1835
+ data[1] === 0x5b &&
1836
+ data[2] === 0x31 &&
1837
+ data[3] === 0x3b &&
1838
+ data[4] === 0x39 &&
1839
+ data[5] === 0x44);
1840
+ // Mac Option+Right (word right): ESC f or ESC [ 1 ; 9 C
1841
+ const isOptionRight = (data.length === 2 && data[0] === 0x1b && data[1] === 0x66) ||
1842
+ (data.length === 6 &&
1843
+ data[0] === 0x1b &&
1844
+ data[1] === 0x5b &&
1845
+ data[2] === 0x31 &&
1846
+ data[3] === 0x3b &&
1847
+ data[4] === 0x39 &&
1848
+ data[5] === 0x43);
1849
+ if (isOptionLeft) {
1850
+ this.handleWordLeft();
1851
+ return;
1852
+ }
1853
+ if (isOptionRight) {
1854
+ this.handleWordRight();
1855
+ return;
1856
+ }
1857
+ // Home/Cmd+Left: Ctrl+A (\x01) or ESC [ H
1858
+ const isHome = (data.length === 1 && data[0] === 0x01) ||
1859
+ (data.length === 3 && data[0] === 0x1b && data[1] === 0x5b && data[2] === 0x48);
1860
+ // End/Cmd+Right: Ctrl+E (\x05) or ESC [ F
1861
+ const isEnd = (data.length === 1 && data[0] === 0x05) ||
1862
+ (data.length === 3 && data[0] === 0x1b && data[1] === 0x5b && data[2] === 0x46);
1863
+ if (isHome) {
1864
+ this.cursorPos = 0;
1865
+ this.needsRender = true;
1866
+ return;
1867
+ }
1868
+ if (isEnd) {
1869
+ this.cursorPos = this.lines[this.currentLine].length;
1870
+ this.needsRender = true;
1871
+ return;
1872
+ }
1873
+ };
1874
+ this.keyHandler = (str, key) => {
1875
+ if (!this.isRunning || this.isPaused)
1876
+ return;
1877
+ // Skip escape here - handled by dataHandler
1878
+ if (key.name === 'escape')
1879
+ return;
1880
+ this.handleKeypress(str, key);
1881
+ };
1882
+ process.stdin.on('data', this.dataHandler);
1883
+ process.stdin.on('keypress', this.keyHandler);
1884
+ process.stdin.resume();
1885
+ }
1886
+ stopKeyboardCapture() {
1887
+ if (this.dataHandler) {
1888
+ process.stdin.removeListener('data', this.dataHandler);
1889
+ this.dataHandler = null;
1890
+ }
1891
+ if (this.keyHandler) {
1892
+ process.stdin.removeListener('keypress', this.keyHandler);
1893
+ this.keyHandler = null;
1894
+ }
1895
+ if (process.stdin.isTTY) {
1896
+ process.stdin.setRawMode(false);
1897
+ }
1898
+ }
1899
+ /**
1900
+ * Handle word left (Option+Left) - move cursor to previous word boundary
1901
+ */
1902
+ handleWordLeft() {
1903
+ const line = this.lines[this.currentLine];
1904
+ if (this.cursorPos > 0) {
1905
+ let pos = this.cursorPos;
1906
+ // Skip spaces
1907
+ while (pos > 0 && line[pos - 1] === ' ')
1908
+ pos--;
1909
+ // Skip word
1910
+ while (pos > 0 && line[pos - 1] !== ' ')
1911
+ pos--;
1912
+ this.cursorPos = pos;
1913
+ this.needsRender = true;
1914
+ }
1915
+ else if (this.currentLine > 0) {
1916
+ // Move to end of previous line
1917
+ this.currentLine--;
1918
+ this.cursorPos = this.lines[this.currentLine].length;
1919
+ this.needsRender = true;
1920
+ }
1921
+ }
1922
+ /**
1923
+ * Handle word right (Option+Right) - move cursor to next word boundary
1924
+ */
1925
+ handleWordRight() {
1926
+ const line = this.lines[this.currentLine];
1927
+ if (this.cursorPos < line.length) {
1928
+ let pos = this.cursorPos;
1929
+ // Skip word
1930
+ while (pos < line.length && line[pos] !== ' ')
1931
+ pos++;
1932
+ // Skip spaces
1933
+ while (pos < line.length && line[pos] === ' ')
1934
+ pos++;
1935
+ this.cursorPos = pos;
1936
+ this.needsRender = true;
1937
+ }
1938
+ else if (this.currentLine < this.lines.length - 1) {
1939
+ // Move to start of next line
1940
+ this.currentLine++;
1941
+ this.cursorPos = 0;
1942
+ this.needsRender = true;
1943
+ }
1944
+ }
1945
+ /**
1946
+ * Handle Escape key - called from raw data handler for reliable detection
1947
+ */
1948
+ handleEscape() {
1949
+ // In verbose-temp mode, escape returns to normal
1950
+ if (this.viewMode === 'verbose-temp') {
1951
+ this.viewMode = 'normal';
1952
+ this.reRenderConversationVerbose(false);
1953
+ return;
1954
+ }
1955
+ // Route escape to active overlay if present
1956
+ const overlay = this.getActiveOverlay();
1957
+ if (overlay) {
1958
+ const keyEvent = {
1959
+ raw: Buffer.from([0x1b]),
1960
+ name: 'escape',
1961
+ ctrl: false,
1962
+ shift: false,
1963
+ meta: false,
1964
+ };
1965
+ const actionOrPromise = overlay.handleKey(keyEvent);
1966
+ // Handle both sync and async handleKey
1967
+ if (actionOrPromise instanceof Promise) {
1968
+ actionOrPromise
1969
+ .then((action) => { this.processOverlayAction(action); })
1970
+ .catch((err) => { console.error('Overlay handleKey error:', err); });
1971
+ }
1972
+ else {
1973
+ this.processOverlayAction(actionOrPromise);
1974
+ }
1975
+ return;
1976
+ }
1977
+ const now = Date.now();
1978
+ const timeSinceLastEsc = now - this.lastEscapeTime;
1979
+ const isDoubleEsc = timeSinceLastEsc < 500;
1980
+ this.lastEscapeTime = now;
1981
+ if (this.fileAutocomplete.active) {
1982
+ // 1. Close file autocomplete first
1983
+ this.closeAutocomplete();
1984
+ this.needsRender = true;
1985
+ }
1986
+ else if (this.autocomplete.active) {
1987
+ // 2. Close command autocomplete
1988
+ this.closeAutocomplete();
1989
+ this.needsRender = true;
1990
+ }
1991
+ else if (isDoubleEsc && this.getInputValue().length > 0) {
1992
+ // 3. Double Esc clears input (if there's content)
1993
+ this.clearInput();
1994
+ this.resetHistoryNavigation();
1995
+ this.needsRender = true;
1996
+ }
1997
+ else if (this.agentRunning) {
1998
+ // 4. Single Esc cancels agent
1999
+ this.emit('cancel');
2000
+ }
2001
+ else {
2002
+ // 5. Single Esc emits escape
2003
+ this.emit('escape');
2004
+ }
2005
+ }
2006
+ /**
2007
+ * Handle keypress when overlay is active.
2008
+ * Converts keyboard event to KeyEvent and routes to overlay.
2009
+ */
2010
+ handleOverlayKeypress(str, key) {
2011
+ const overlay = this.getActiveOverlay();
2012
+ if (!overlay)
2013
+ return;
2014
+ // Build KeyEvent from keypress data
2015
+ const rawData = key.sequence ?? str;
2016
+ const keyName = key.name ?? (str ? str.toLowerCase() : '');
2017
+ const keyEvent = {
2018
+ raw: Buffer.from(rawData),
2019
+ name: keyName,
2020
+ char: str && str.length === 1 && str.charCodeAt(0) >= 32 ? str : undefined,
2021
+ ctrl: key.ctrl ?? false,
2022
+ shift: key.shift ?? false,
2023
+ meta: key.meta ?? false,
2024
+ };
2025
+ const actionOrPromise = overlay.handleKey(keyEvent);
2026
+ // Handle both sync and async handleKey
2027
+ if (actionOrPromise instanceof Promise) {
2028
+ actionOrPromise
2029
+ .then((action) => { this.processOverlayAction(action); })
2030
+ .catch((err) => { console.error('Overlay handleKey error:', err); });
2031
+ }
2032
+ else {
2033
+ this.processOverlayAction(actionOrPromise);
2034
+ }
2035
+ }
2036
+ handleKeypress(str, key) {
2037
+ // Route to active overlay if present
2038
+ if (this.hasActiveOverlay()) {
2039
+ this.handleOverlayKeypress(str, key);
2040
+ return;
2041
+ }
2042
+ // In verbose-temp mode, any key (except Ctrl+O) returns to normal
2043
+ if (this.viewMode === 'verbose-temp') {
2044
+ // Ctrl+O toggles back
2045
+ if (key.ctrl && key.name === 'o') {
2046
+ this.toggleVerboseView();
2047
+ this.toggleLiveRegionExpanded();
2048
+ return;
2049
+ }
2050
+ // Any other key returns to normal
2051
+ this.viewMode = 'normal';
2052
+ this.reRenderConversationVerbose(false);
2053
+ // Don't consume the key - let it be processed normally
2054
+ }
2055
+ // Ctrl+C - interrupt/exit
2056
+ if (key.ctrl && key.name === 'c') {
2057
+ this.emit('interrupt');
2058
+ return;
2059
+ }
2060
+ // Ctrl+T - toggle todo list visibility
2061
+ if (key.ctrl && key.name === 't') {
2062
+ this.toggleTodos();
2063
+ return;
2064
+ }
2065
+ // Ctrl+O - toggle verbose view mode AND LiveRegion expansion
2066
+ if (key.ctrl && key.name === 'o') {
2067
+ this.toggleVerboseView();
2068
+ this.toggleLiveRegionExpanded();
2069
+ return;
2070
+ }
2071
+ // Tab - accept suggestion, or autocomplete
2072
+ if (key.name === 'tab' && !key.shift) {
2073
+ // Accept ghost text suggestion if input is empty
2074
+ if (this.suggestion && this.getInputValue() === '') {
2075
+ this.lines[0] = this.suggestion;
2076
+ this.cursorPos = this.suggestion.length;
2077
+ this.suggestion = null;
2078
+ this.needsRender = true;
2079
+ return;
2080
+ }
2081
+ if (this.fileAutocomplete.active && this.fileAutocomplete.matches.length > 0) {
2082
+ this.acceptAutocomplete();
2083
+ this.needsRender = true;
2084
+ return;
2085
+ }
2086
+ if (this.autocomplete.active && this.autocomplete.matches.length > 0) {
2087
+ this.acceptAutocomplete();
2088
+ this.needsRender = true;
2089
+ return;
2090
+ }
2091
+ // Otherwise ignore tab (or could insert spaces)
2092
+ return;
2093
+ }
2094
+ // Enter - handle multiline continuation, autocomplete, then execute
2095
+ if (key.name === 'return') {
2096
+ // Check for backslash continuation at end of current line
2097
+ const currentLine = this.getCurrentLineContent();
2098
+ if (currentLine.endsWith('\\')) {
2099
+ // Remove backslash and add new line
2100
+ this.lines[this.currentLine] = currentLine.slice(0, -1);
2101
+ this.lines.push('');
2102
+ this.currentLine++;
2103
+ this.cursorPos = 0;
2104
+ this.closeAutocomplete();
2105
+ this.resetHistoryNavigation();
2106
+ this.needsRender = true;
2107
+ return;
2108
+ }
2109
+ // If file autocomplete is active, accept the selection and submit
2110
+ if (this.fileAutocomplete.active && this.fileAutocomplete.matches.length > 0) {
2111
+ this.acceptAutocomplete();
2112
+ // Fall through to submit the message
2113
+ }
2114
+ // If command autocomplete is active, accept the selection first
2115
+ if (this.autocomplete.active && this.autocomplete.matches.length > 0) {
2116
+ this.acceptAutocomplete();
2117
+ // Fall through to execute the command
2118
+ }
2119
+ let input = this.getInputValue().trim();
2120
+ // If input is empty but we have a ghost text suggestion, accept it
2121
+ if (!input && this.suggestion) {
2122
+ input = this.suggestion;
2123
+ this.suggestion = null;
2124
+ }
2125
+ if (input) {
2126
+ // Add to history before processing (store as single line for history)
2127
+ this.addToHistory(input.replace(/\n/g, ' '));
2128
+ this.resetHistoryNavigation();
2129
+ if (this.agentRunning) {
2130
+ this.queuedInputs.push(input);
2131
+ this.needsRender = true;
2132
+ }
2133
+ else if (input.startsWith('/')) {
2134
+ const spaceIndex = input.indexOf(' ');
2135
+ const cmd = spaceIndex > 0 ? input.slice(1, spaceIndex) : input.slice(1);
2136
+ const args = spaceIndex > 0 ? input.slice(spaceIndex + 1) : '';
2137
+ this.closeAutocomplete();
2138
+ this.emit('command', cmd, args);
2139
+ }
2140
+ else {
2141
+ this.emit('submit', input);
2142
+ }
2143
+ this.clearInput();
2144
+ this.closeAutocomplete();
2145
+ this.needsRender = true;
2146
+ }
2147
+ return;
2148
+ }
2149
+ // Arrow Up - autocomplete > multiline navigation > history
2150
+ if (key.name === 'up') {
2151
+ // 1. Autocomplete navigation takes priority
2152
+ if (this.navigateAutocompleteUp()) {
2153
+ this.needsRender = true;
2154
+ return;
2155
+ }
2156
+ // 2. Navigate to previous line in multiline input
2157
+ if (this.currentLine > 0) {
2158
+ this.currentLine--;
2159
+ // Try to keep same cursor position, but clamp to line length
2160
+ this.cursorPos = Math.min(this.cursorPos, this.lines[this.currentLine].length);
2161
+ this.needsRender = true;
2162
+ return;
2163
+ }
2164
+ // 3. History navigation (only when at first line)
2165
+ if (this.navigateHistoryUp()) {
2166
+ this.closeAutocomplete();
2167
+ this.needsRender = true;
2168
+ }
2169
+ return;
2170
+ }
2171
+ // Arrow Down - autocomplete > multiline navigation > history
2172
+ if (key.name === 'down') {
2173
+ // 1. Autocomplete navigation takes priority
2174
+ if (this.navigateAutocompleteDown()) {
2175
+ this.needsRender = true;
2176
+ return;
2177
+ }
2178
+ // 2. Navigate to next line in multiline input
2179
+ if (this.currentLine < this.lines.length - 1) {
2180
+ this.currentLine++;
2181
+ // Try to keep same cursor position, but clamp to line length
2182
+ this.cursorPos = Math.min(this.cursorPos, this.lines[this.currentLine].length);
2183
+ this.needsRender = true;
2184
+ return;
2185
+ }
2186
+ // 3. History forward (only when at last line)
2187
+ if (this.historyIndex >= 0 && this.navigateHistoryDown()) {
2188
+ this.needsRender = true;
2189
+ }
2190
+ return;
2191
+ }
2192
+ // Backspace
2193
+ if (key.name === 'backspace') {
2194
+ if (this.cursorPos > 0) {
2195
+ // Delete character in current line
2196
+ const line = this.lines[this.currentLine];
2197
+ this.lines[this.currentLine] = line.slice(0, this.cursorPos - 1) + line.slice(this.cursorPos);
2198
+ this.cursorPos--;
2199
+ this.resetHistoryNavigation();
2200
+ this.updateAutocomplete();
2201
+ this.needsRender = true;
2202
+ }
2203
+ else if (this.currentLine > 0) {
2204
+ // At start of line - merge with previous line
2205
+ const currentLine = this.lines[this.currentLine];
2206
+ const prevLine = this.lines[this.currentLine - 1];
2207
+ this.lines[this.currentLine - 1] = prevLine + currentLine;
2208
+ this.lines.splice(this.currentLine, 1);
2209
+ this.currentLine--;
2210
+ this.cursorPos = prevLine.length;
2211
+ this.resetHistoryNavigation();
2212
+ this.updateAutocomplete();
2213
+ this.needsRender = true;
2214
+ }
2215
+ return;
2216
+ }
2217
+ // Delete
2218
+ if (key.name === 'delete') {
2219
+ const line = this.lines[this.currentLine];
2220
+ if (this.cursorPos < line.length) {
2221
+ // Delete character in current line
2222
+ this.lines[this.currentLine] = line.slice(0, this.cursorPos) + line.slice(this.cursorPos + 1);
2223
+ this.resetHistoryNavigation();
2224
+ this.updateAutocomplete();
2225
+ this.needsRender = true;
2226
+ }
2227
+ else if (this.currentLine < this.lines.length - 1) {
2228
+ // At end of line - merge with next line
2229
+ const nextLine = this.lines[this.currentLine + 1];
2230
+ this.lines[this.currentLine] = line + nextLine;
2231
+ this.lines.splice(this.currentLine + 1, 1);
2232
+ this.resetHistoryNavigation();
2233
+ this.updateAutocomplete();
2234
+ this.needsRender = true;
2235
+ }
2236
+ return;
2237
+ }
2238
+ // Arrow keys (left/right for cursor movement with multiline support)
2239
+ if (key.name === 'left') {
2240
+ if (this.cursorPos > 0) {
2241
+ this.cursorPos--;
2242
+ this.needsRender = true;
2243
+ }
2244
+ else if (this.currentLine > 0) {
2245
+ // Move to end of previous line
2246
+ this.currentLine--;
2247
+ this.cursorPos = this.lines[this.currentLine].length;
2248
+ this.needsRender = true;
2249
+ }
2250
+ return;
2251
+ }
2252
+ if (key.name === 'right') {
2253
+ const lineLen = this.lines[this.currentLine].length;
2254
+ if (this.cursorPos < lineLen) {
2255
+ this.cursorPos++;
2256
+ this.needsRender = true;
2257
+ }
2258
+ else if (this.currentLine < this.lines.length - 1) {
2259
+ // Move to start of next line
2260
+ this.currentLine++;
2261
+ this.cursorPos = 0;
2262
+ this.needsRender = true;
2263
+ }
2264
+ return;
2265
+ }
2266
+ if (key.name === 'home') {
2267
+ this.cursorPos = 0;
2268
+ this.needsRender = true;
2269
+ return;
2270
+ }
2271
+ if (key.name === 'end') {
2272
+ this.cursorPos = this.lines[this.currentLine].length;
2273
+ this.needsRender = true;
2274
+ return;
2275
+ }
2276
+ // Shift+Tab - mode change
2277
+ if (key.shift && key.name === 'tab') {
2278
+ this.emit('modeChange');
2279
+ return;
2280
+ }
2281
+ // Regular character
2282
+ if (str && str.length === 1 && str.charCodeAt(0) >= 32) {
2283
+ // Clear ghost text suggestion when user starts typing
2284
+ if (this.suggestion) {
2285
+ this.suggestion = null;
2286
+ }
2287
+ const line = this.lines[this.currentLine];
2288
+ this.lines[this.currentLine] =
2289
+ line.slice(0, this.cursorPos) + str + line.slice(this.cursorPos);
2290
+ this.cursorPos++;
2291
+ this.resetHistoryNavigation();
2292
+ this.updateAutocomplete();
2293
+ this.needsRender = true;
2294
+ }
2295
+ }
2296
+ }