@travisennis/acai 0.0.6 → 0.0.7

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 (344) hide show
  1. package/README.md +186 -17
  2. package/bin/acai-wrapper.js +26 -0
  3. package/dist/agent/index.d.ts +15 -2
  4. package/dist/agent/index.d.ts.map +1 -1
  5. package/dist/agent/index.js +202 -174
  6. package/dist/api/exa/index.js +1 -1
  7. package/dist/cli.d.ts +2 -1
  8. package/dist/cli.d.ts.map +1 -1
  9. package/dist/cli.js +40 -7
  10. package/dist/commands/add-directory-command.d.ts +1 -1
  11. package/dist/commands/add-directory-command.d.ts.map +1 -1
  12. package/dist/commands/add-directory-command.js +1 -32
  13. package/dist/commands/application-log-command.d.ts +1 -1
  14. package/dist/commands/application-log-command.d.ts.map +1 -1
  15. package/dist/commands/application-log-command.js +2 -38
  16. package/dist/commands/clear-command.d.ts +1 -1
  17. package/dist/commands/clear-command.d.ts.map +1 -1
  18. package/dist/commands/clear-command.js +1 -5
  19. package/dist/commands/compact-command.d.ts.map +1 -1
  20. package/dist/commands/compact-command.js +0 -9
  21. package/dist/commands/context-command.d.ts +1 -1
  22. package/dist/commands/context-command.d.ts.map +1 -1
  23. package/dist/commands/context-command.js +13 -72
  24. package/dist/commands/copy-command.d.ts.map +1 -1
  25. package/dist/commands/copy-command.js +0 -19
  26. package/dist/commands/edit-command.d.ts +1 -1
  27. package/dist/commands/edit-command.d.ts.map +1 -1
  28. package/dist/commands/edit-command.js +3 -49
  29. package/dist/commands/edit-prompt-command.d.ts +1 -1
  30. package/dist/commands/edit-prompt-command.d.ts.map +1 -1
  31. package/dist/commands/edit-prompt-command.js +1 -26
  32. package/dist/commands/exit-command.d.ts +1 -4
  33. package/dist/commands/exit-command.d.ts.map +1 -1
  34. package/dist/commands/exit-command.js +2 -18
  35. package/dist/commands/files-command.d.ts +1 -1
  36. package/dist/commands/files-command.d.ts.map +1 -1
  37. package/dist/commands/files-command.js +1 -54
  38. package/dist/commands/generate-rules-command.d.ts +1 -1
  39. package/dist/commands/generate-rules-command.d.ts.map +1 -1
  40. package/dist/commands/generate-rules-command.js +18 -60
  41. package/dist/commands/handoff-command.d.ts.map +1 -1
  42. package/dist/commands/handoff-command.js +0 -11
  43. package/dist/commands/health-command.d.ts +1 -1
  44. package/dist/commands/health-command.d.ts.map +1 -1
  45. package/dist/commands/health-command.js +8 -103
  46. package/dist/commands/help-command.d.ts +1 -1
  47. package/dist/commands/help-command.d.ts.map +1 -1
  48. package/dist/commands/help-command.js +6 -14
  49. package/dist/commands/history-command.d.ts +1 -1
  50. package/dist/commands/history-command.d.ts.map +1 -1
  51. package/dist/commands/history-command.js +30 -106
  52. package/dist/commands/init-command.d.ts +1 -1
  53. package/dist/commands/init-command.d.ts.map +1 -1
  54. package/dist/commands/init-command.js +4 -23
  55. package/dist/commands/last-log-command.d.ts +1 -1
  56. package/dist/commands/last-log-command.d.ts.map +1 -1
  57. package/dist/commands/last-log-command.js +1 -28
  58. package/dist/commands/list-directories-command.d.ts +1 -1
  59. package/dist/commands/list-directories-command.d.ts.map +1 -1
  60. package/dist/commands/list-directories-command.js +1 -14
  61. package/dist/commands/list-tools-command.d.ts.map +1 -1
  62. package/dist/commands/list-tools-command.js +55 -78
  63. package/dist/commands/manager.d.ts +1 -8
  64. package/dist/commands/manager.d.ts.map +1 -1
  65. package/dist/commands/manager.js +7 -42
  66. package/dist/commands/model-command.d.ts +0 -22
  67. package/dist/commands/model-command.d.ts.map +1 -1
  68. package/dist/commands/model-command.js +5 -126
  69. package/dist/commands/paste-command.d.ts +1 -1
  70. package/dist/commands/paste-command.d.ts.map +1 -1
  71. package/dist/commands/paste-command.js +1 -79
  72. package/dist/commands/pickup-command.d.ts.map +1 -1
  73. package/dist/commands/pickup-command.js +3 -55
  74. package/dist/commands/prompt-command.d.ts +19 -1
  75. package/dist/commands/prompt-command.d.ts.map +1 -1
  76. package/dist/commands/prompt-command.js +172 -194
  77. package/dist/commands/remove-directory-command.d.ts +1 -1
  78. package/dist/commands/remove-directory-command.d.ts.map +1 -1
  79. package/dist/commands/remove-directory-command.js +1 -33
  80. package/dist/commands/reset-command.d.ts +1 -1
  81. package/dist/commands/reset-command.d.ts.map +1 -1
  82. package/dist/commands/reset-command.js +3 -11
  83. package/dist/commands/rules-command.d.ts +1 -1
  84. package/dist/commands/rules-command.d.ts.map +1 -1
  85. package/dist/commands/rules-command.js +1 -63
  86. package/dist/commands/save-command.d.ts +1 -1
  87. package/dist/commands/save-command.d.ts.map +1 -1
  88. package/dist/commands/save-command.js +1 -8
  89. package/dist/commands/shell-command.d.ts.map +1 -1
  90. package/dist/commands/shell-command.js +1 -48
  91. package/dist/commands/types.d.ts +0 -3
  92. package/dist/commands/types.d.ts.map +1 -1
  93. package/dist/commands/usage-command.d.ts +1 -1
  94. package/dist/commands/usage-command.d.ts.map +1 -1
  95. package/dist/commands/usage-command.js +5 -16
  96. package/dist/config.d.ts +17 -6
  97. package/dist/config.d.ts.map +1 -1
  98. package/dist/config.js +86 -53
  99. package/dist/execution/index.d.ts +17 -2
  100. package/dist/execution/index.d.ts.map +1 -1
  101. package/dist/execution/index.js +62 -20
  102. package/dist/formatting.d.ts +19 -0
  103. package/dist/formatting.d.ts.map +1 -1
  104. package/dist/formatting.js +54 -0
  105. package/dist/index.d.ts +1 -1
  106. package/dist/index.d.ts.map +1 -1
  107. package/dist/index.js +212 -153
  108. package/dist/messages.d.ts +3 -0
  109. package/dist/messages.d.ts.map +1 -1
  110. package/dist/messages.js +67 -3
  111. package/dist/models/anthropic-provider.d.ts.map +1 -1
  112. package/dist/models/anthropic-provider.js +0 -7
  113. package/dist/models/deepseek-provider.d.ts.map +1 -1
  114. package/dist/models/deepseek-provider.js +0 -2
  115. package/dist/models/google-provider.d.ts.map +1 -1
  116. package/dist/models/google-provider.js +0 -3
  117. package/dist/models/groq-provider.d.ts.map +1 -1
  118. package/dist/models/groq-provider.js +0 -1
  119. package/dist/models/openai-provider.d.ts.map +1 -1
  120. package/dist/models/openai-provider.js +0 -4
  121. package/dist/models/openrouter-provider.d.ts +10 -9
  122. package/dist/models/openrouter-provider.d.ts.map +1 -1
  123. package/dist/models/openrouter-provider.js +82 -88
  124. package/dist/models/providers.d.ts +4 -14
  125. package/dist/models/providers.d.ts.map +1 -1
  126. package/dist/models/providers.js +1 -57
  127. package/dist/models/xai-provider.d.ts.map +1 -1
  128. package/dist/models/xai-provider.js +0 -2
  129. package/dist/prompts.d.ts +9 -4
  130. package/dist/prompts.d.ts.map +1 -1
  131. package/dist/prompts.js +427 -99
  132. package/dist/repl/project-status-line.d.ts +1 -0
  133. package/dist/repl/project-status-line.d.ts.map +1 -1
  134. package/dist/repl/project-status-line.js +57 -27
  135. package/dist/repl-new.d.ts +0 -2
  136. package/dist/repl-new.d.ts.map +1 -1
  137. package/dist/repl-new.js +34 -54
  138. package/dist/skills.d.ts +20 -0
  139. package/dist/skills.d.ts.map +1 -0
  140. package/dist/skills.js +192 -0
  141. package/dist/terminal/control.d.ts +55 -0
  142. package/dist/terminal/control.d.ts.map +1 -0
  143. package/dist/terminal/control.js +109 -0
  144. package/dist/terminal/default-theme.d.ts +1 -1
  145. package/dist/terminal/default-theme.d.ts.map +1 -1
  146. package/dist/terminal/default-theme.js +24 -28
  147. package/dist/terminal/formatting.d.ts +23 -25
  148. package/dist/terminal/formatting.d.ts.map +1 -1
  149. package/dist/terminal/formatting.js +35 -52
  150. package/dist/terminal/highlight/index.d.ts.map +1 -1
  151. package/dist/terminal/highlight/index.js +3 -6
  152. package/dist/terminal/highlight/theme.d.ts.map +1 -1
  153. package/dist/terminal/highlight/theme.js +2 -6
  154. package/dist/terminal/index.d.ts +2 -101
  155. package/dist/terminal/index.d.ts.map +1 -1
  156. package/dist/terminal/index.js +2 -464
  157. package/dist/terminal/markdown.js +7 -5
  158. package/dist/terminal/strip-ansi.js +4 -4
  159. package/dist/terminal/table/cell.d.ts +114 -0
  160. package/dist/terminal/table/cell.d.ts.map +1 -0
  161. package/dist/terminal/table/cell.js +407 -0
  162. package/dist/terminal/table/debug.d.ts +15 -0
  163. package/dist/terminal/table/debug.d.ts.map +1 -0
  164. package/dist/terminal/table/debug.js +32 -0
  165. package/dist/terminal/table/index.d.ts +3 -0
  166. package/dist/terminal/table/index.d.ts.map +1 -0
  167. package/dist/terminal/table/index.js +2 -0
  168. package/dist/terminal/table/layout-manager.d.ts +27 -0
  169. package/dist/terminal/table/layout-manager.d.ts.map +1 -0
  170. package/dist/terminal/table/layout-manager.js +257 -0
  171. package/dist/terminal/table/table.d.ts +9 -0
  172. package/dist/terminal/table/table.d.ts.map +1 -0
  173. package/dist/terminal/table/table.js +97 -0
  174. package/dist/terminal/table/utils.d.ts +63 -0
  175. package/dist/terminal/table/utils.d.ts.map +1 -0
  176. package/dist/terminal/table/utils.js +326 -0
  177. package/dist/tokens/threshold.d.ts +6 -21
  178. package/dist/tokens/threshold.d.ts.map +1 -1
  179. package/dist/tokens/threshold.js +13 -31
  180. package/dist/tools/advanced-edit-file.d.ts.map +1 -1
  181. package/dist/tools/advanced-edit-file.js +5 -1
  182. package/dist/tools/agent.d.ts.map +1 -1
  183. package/dist/tools/agent.js +19 -5
  184. package/dist/tools/bash.d.ts +3 -1
  185. package/dist/tools/bash.d.ts.map +1 -1
  186. package/dist/tools/bash.js +204 -42
  187. package/dist/tools/batch.d.ts +34 -0
  188. package/dist/tools/batch.d.ts.map +1 -0
  189. package/dist/tools/batch.js +174 -0
  190. package/dist/tools/code-interpreter.d.ts.map +1 -1
  191. package/dist/tools/code-interpreter.js +25 -9
  192. package/dist/tools/delete-file.d.ts.map +1 -1
  193. package/dist/tools/delete-file.js +9 -2
  194. package/dist/tools/directory-tree.d.ts +0 -6
  195. package/dist/tools/directory-tree.d.ts.map +1 -1
  196. package/dist/tools/directory-tree.js +29 -18
  197. package/dist/tools/dynamic-tool-loader.d.ts +0 -4
  198. package/dist/tools/dynamic-tool-loader.d.ts.map +1 -1
  199. package/dist/tools/dynamic-tool-loader.js +2 -2
  200. package/dist/tools/edit-file.d.ts.map +1 -1
  201. package/dist/tools/edit-file.js +16 -3
  202. package/dist/tools/glob.d.ts.map +1 -1
  203. package/dist/tools/glob.js +24 -13
  204. package/dist/tools/grep.d.ts.map +1 -1
  205. package/dist/tools/grep.js +40 -25
  206. package/dist/tools/index.d.ts +17 -3
  207. package/dist/tools/index.d.ts.map +1 -1
  208. package/dist/tools/index.js +43 -1
  209. package/dist/tools/llm-edit-fixer.d.ts +0 -1
  210. package/dist/tools/llm-edit-fixer.d.ts.map +1 -1
  211. package/dist/tools/llm-edit-fixer.js +14 -28
  212. package/dist/tools/move-file.d.ts.map +1 -1
  213. package/dist/tools/move-file.js +8 -1
  214. package/dist/tools/read-file.d.ts.map +1 -1
  215. package/dist/tools/read-file.js +32 -23
  216. package/dist/tools/read-multiple-files.d.ts.map +1 -1
  217. package/dist/tools/read-multiple-files.js +102 -45
  218. package/dist/tools/save-file.d.ts +4 -4
  219. package/dist/tools/save-file.d.ts.map +1 -1
  220. package/dist/tools/save-file.js +20 -2
  221. package/dist/tools/think.d.ts.map +1 -1
  222. package/dist/tools/think.js +7 -1
  223. package/dist/tools/types.d.ts +5 -1
  224. package/dist/tools/types.d.ts.map +1 -1
  225. package/dist/tools/web-fetch.js +1 -1
  226. package/dist/tools/web-search.d.ts.map +1 -1
  227. package/dist/tools/web-search.js +24 -9
  228. package/dist/tui/components/assistant-message.js +1 -1
  229. package/dist/tui/components/box.d.ts +20 -0
  230. package/dist/tui/components/box.d.ts.map +1 -0
  231. package/dist/tui/components/box.js +81 -0
  232. package/dist/tui/components/editor.d.ts +60 -5
  233. package/dist/tui/components/editor.d.ts.map +1 -1
  234. package/dist/tui/components/editor.js +577 -115
  235. package/dist/tui/components/footer.d.ts +0 -12
  236. package/dist/tui/components/footer.d.ts.map +1 -1
  237. package/dist/tui/components/footer.js +19 -7
  238. package/dist/tui/components/header.d.ts +21 -0
  239. package/dist/tui/components/header.d.ts.map +1 -0
  240. package/dist/tui/components/header.js +63 -0
  241. package/dist/tui/components/loader.d.ts +5 -1
  242. package/dist/tui/components/loader.d.ts.map +1 -1
  243. package/dist/tui/components/loader.js +2 -2
  244. package/dist/tui/components/markdown.d.ts +26 -23
  245. package/dist/tui/components/markdown.d.ts.map +1 -1
  246. package/dist/tui/components/markdown.js +107 -54
  247. package/dist/tui/components/modal.d.ts +0 -11
  248. package/dist/tui/components/modal.d.ts.map +1 -1
  249. package/dist/tui/components/modal.js +0 -29
  250. package/dist/tui/components/progress-bar.d.ts +19 -0
  251. package/dist/tui/components/progress-bar.d.ts.map +1 -0
  252. package/dist/tui/components/progress-bar.js +78 -0
  253. package/dist/tui/components/prompt-status.d.ts +2 -1
  254. package/dist/tui/components/prompt-status.d.ts.map +1 -1
  255. package/dist/tui/components/prompt-status.js +7 -2
  256. package/dist/tui/components/select-list.d.ts +27 -1
  257. package/dist/tui/components/select-list.d.ts.map +1 -1
  258. package/dist/tui/components/select-list.js +93 -29
  259. package/dist/tui/components/spacer.d.ts +1 -1
  260. package/dist/tui/components/spacer.d.ts.map +1 -1
  261. package/dist/tui/components/spacer.js +2 -2
  262. package/dist/tui/components/table.d.ts +27 -0
  263. package/dist/tui/components/table.d.ts.map +1 -0
  264. package/dist/tui/components/table.js +125 -0
  265. package/dist/tui/components/thinking-block.d.ts.map +1 -1
  266. package/dist/tui/components/thinking-block.js +4 -1
  267. package/dist/tui/components/tool-execution.d.ts +8 -4
  268. package/dist/tui/components/tool-execution.d.ts.map +1 -1
  269. package/dist/tui/components/tool-execution.js +88 -80
  270. package/dist/tui/components/user-message.d.ts.map +1 -1
  271. package/dist/tui/components/user-message.js +6 -4
  272. package/dist/tui/index.d.ts +9 -5
  273. package/dist/tui/index.d.ts.map +1 -1
  274. package/dist/tui/index.js +5 -1
  275. package/dist/tui/terminal.d.ts +2 -1
  276. package/dist/tui/terminal.d.ts.map +1 -1
  277. package/dist/tui/terminal.js +28 -38
  278. package/dist/tui/tui.d.ts +2 -0
  279. package/dist/tui/tui.d.ts.map +1 -1
  280. package/dist/tui/tui.js +53 -33
  281. package/dist/tui/utils.d.ts +5 -0
  282. package/dist/tui/utils.d.ts.map +1 -1
  283. package/dist/tui/utils.js +81 -1
  284. package/dist/{tools/bash-utils.d.ts → utils/bash.d.ts} +3 -3
  285. package/dist/utils/bash.d.ts.map +1 -0
  286. package/dist/{tools/bash-utils.js → utils/bash.js} +22 -11
  287. package/dist/utils/{filesystem.d.ts → filesystem/operations.d.ts} +1 -1
  288. package/dist/utils/filesystem/operations.d.ts.map +1 -0
  289. package/dist/{tools/filesystem-utils.d.ts → utils/filesystem/security.d.ts} +3 -2
  290. package/dist/utils/filesystem/security.d.ts.map +1 -0
  291. package/dist/{tools/filesystem-utils.js → utils/filesystem/security.js} +62 -4
  292. package/dist/utils/funcs.d.ts +6 -0
  293. package/dist/utils/funcs.d.ts.map +1 -0
  294. package/dist/utils/funcs.js +6 -0
  295. package/dist/{tools/git-utils.d.ts → utils/git.d.ts} +1 -1
  296. package/dist/utils/git.d.ts.map +1 -0
  297. package/dist/{tools/git-utils.js → utils/git.js} +0 -6
  298. package/dist/utils/glob.js +1 -1
  299. package/dist/utils/{zod-utils.d.ts → zod.d.ts} +1 -1
  300. package/dist/utils/zod.d.ts.map +1 -0
  301. package/package.json +17 -17
  302. package/dist/agent/manual-loop.d.ts +0 -41
  303. package/dist/agent/manual-loop.d.ts.map +0 -1
  304. package/dist/agent/manual-loop.js +0 -278
  305. package/dist/repl/display-tool-messages.d.ts +0 -4
  306. package/dist/repl/display-tool-messages.d.ts.map +0 -1
  307. package/dist/repl/display-tool-messages.js +0 -58
  308. package/dist/repl/display-tool-use.d.ts +0 -14
  309. package/dist/repl/display-tool-use.d.ts.map +0 -1
  310. package/dist/repl/display-tool-use.js +0 -63
  311. package/dist/repl/get-prompt-header.d.ts +0 -8
  312. package/dist/repl/get-prompt-header.d.ts.map +0 -1
  313. package/dist/repl/get-prompt-header.js +0 -9
  314. package/dist/repl/prompt.d.ts +0 -21
  315. package/dist/repl/prompt.d.ts.map +0 -1
  316. package/dist/repl/prompt.js +0 -244
  317. package/dist/repl.d.ts +0 -29
  318. package/dist/repl.d.ts.map +0 -1
  319. package/dist/repl.js +0 -218
  320. package/dist/terminal/checkbox-prompt.d.ts +0 -36
  321. package/dist/terminal/checkbox-prompt.d.ts.map +0 -1
  322. package/dist/terminal/checkbox-prompt.js +0 -368
  323. package/dist/terminal/editor-prompt.d.ts +0 -10
  324. package/dist/terminal/editor-prompt.d.ts.map +0 -1
  325. package/dist/terminal/editor-prompt.js +0 -61
  326. package/dist/terminal/errors.d.ts +0 -19
  327. package/dist/terminal/errors.d.ts.map +0 -1
  328. package/dist/terminal/errors.js +0 -37
  329. package/dist/terminal/input-prompt.d.ts +0 -17
  330. package/dist/terminal/input-prompt.d.ts.map +0 -1
  331. package/dist/terminal/input-prompt.js +0 -181
  332. package/dist/terminal/search-prompt.d.ts +0 -20
  333. package/dist/terminal/search-prompt.d.ts.map +0 -1
  334. package/dist/terminal/search-prompt.js +0 -280
  335. package/dist/terminal/types.d.ts +0 -35
  336. package/dist/terminal/types.d.ts.map +0 -1
  337. package/dist/terminal/types.js +0 -1
  338. package/dist/tools/bash-utils.d.ts.map +0 -1
  339. package/dist/tools/filesystem-utils.d.ts.map +0 -1
  340. package/dist/tools/git-utils.d.ts.map +0 -1
  341. package/dist/utils/filesystem.d.ts.map +0 -1
  342. package/dist/utils/zod-utils.d.ts.map +0 -1
  343. /package/dist/utils/{filesystem.js → filesystem/operations.js} +0 -0
  344. /package/dist/utils/{zod-utils.js → zod.js} +0 -0
@@ -1,12 +1,76 @@
1
1
  import style from "../../terminal/style.js";
2
- import { SelectList } from "./select-list.js";
2
+ import { visibleWidth } from "../utils.js";
3
+ import { isNavigationKey, isTab, SelectList } from "./select-list.js";
4
+ // Grapheme segmenter for proper Unicode iteration (handles emojis, etc.)
5
+ const segmenter = new Intl.Segmenter();
6
+ // Cache for line metrics to avoid repeated segmentation
7
+ const lineMetricsCache = {
8
+ maxSize: 1000,
9
+ cache: new Map(),
10
+ get(line) {
11
+ let cached = this.cache.get(line);
12
+ if (!cached) {
13
+ // Fast path for ASCII-only lines (common case)
14
+ if (/^[\x20-\x7E\t]*$/.test(line)) {
15
+ // ASCII characters (including tabs)
16
+ const graphemes = line.split(""); // Simple split for ASCII
17
+ const widths = graphemes.map((char) => (char === "\t" ? 3 : 1));
18
+ const totalWidth = widths.reduce((sum, w) => sum + w, 0);
19
+ cached = { graphemes, widths, totalWidth };
20
+ }
21
+ else {
22
+ // Complex Unicode line, use full segmentation
23
+ const graphemes = [...segmenter.segment(line)].map((seg) => seg.segment);
24
+ const widths = graphemes.map((g) => visibleWidth(g));
25
+ const totalWidth = widths.reduce((sum, w) => sum + w, 0);
26
+ cached = { graphemes, widths, totalWidth };
27
+ }
28
+ this.cache.set(line, cached);
29
+ // Enforce size limit
30
+ if (this.cache.size > this.maxSize) {
31
+ // Delete first (oldest) entry - simple but not LRU; okay for our use case
32
+ const firstKey = this.cache.keys().next().value;
33
+ if (firstKey !== undefined) {
34
+ this.cache.delete(firstKey);
35
+ }
36
+ }
37
+ }
38
+ return cached;
39
+ },
40
+ clear() {
41
+ this.cache.clear();
42
+ },
43
+ };
44
+ /**
45
+ * Text editor component with support for multi-line input and autocomplete.
46
+ *
47
+ * Key bindings:
48
+ * - Enter: Create new line
49
+ * - Shift+Enter / Ctrl+Enter / Option+Enter: Submit prompt
50
+ * - Tab: Trigger autocomplete
51
+ * - Escape: Cancel autocomplete or custom handler
52
+ * - Ctrl+C: Custom handler
53
+ * - Arrow keys: Navigate text
54
+ * - Backspace/Delete: Delete characters
55
+ * - Ctrl+A: Move to start of line
56
+ * - Ctrl+E: Move to end of line
57
+ * - Ctrl+K: Delete to end of line
58
+ * - Ctrl+U: Delete to start of line
59
+ * - Ctrl+W / Option+Backspace: Delete word backwards
60
+ * - Ctrl+Left/Right / Option+Left/Right: Word navigation
61
+ * - Up/Down: History navigation when editor is empty
62
+ */
3
63
  export class Editor {
4
64
  state = {
5
65
  lines: [""],
6
66
  cursorLine: 0,
7
67
  cursorCol: 0,
8
68
  };
9
- config = {};
69
+ theme;
70
+ // Store last render width for cursor navigation
71
+ lastWidth = 80;
72
+ // Border color (can be changed dynamically)
73
+ borderColor;
10
74
  // Autocomplete support
11
75
  autocompleteProvider;
12
76
  autocompleteList;
@@ -19,6 +83,9 @@ export class Editor {
19
83
  // Bracketed paste mode buffering
20
84
  pasteBuffer = "";
21
85
  isInPaste = false;
86
+ // Prompt history for up/down navigation
87
+ history = [];
88
+ historyIndex = -1; // -1 = not browsing, 0 = most recent, 1 = older, etc.
22
89
  onSubmit;
23
90
  onChange;
24
91
  disableSubmit = false;
@@ -26,19 +93,78 @@ export class Editor {
26
93
  onEscape;
27
94
  onCtrlC;
28
95
  onRenderRequested;
29
- constructor(config) {
30
- if (config) {
31
- this.config = { ...this.config, ...config };
32
- }
96
+ constructor(theme) {
97
+ // Default theme if none provided (backward compatibility)
98
+ this.theme = theme || {
99
+ borderColor: style.gray,
100
+ };
101
+ this.borderColor = this.theme.borderColor;
33
102
  }
34
- configure(config) {
35
- this.config = { ...this.config, ...config };
103
+ /**
104
+ * Add a prompt to history for up/down arrow navigation.
105
+ * Called after successful submission.
106
+ */
107
+ addToHistory(text) {
108
+ const trimmed = text.trim();
109
+ if (!trimmed)
110
+ return;
111
+ // Don't add consecutive duplicates
112
+ if (this.history.length > 0 && this.history[0] === trimmed)
113
+ return;
114
+ this.history.unshift(trimmed);
115
+ // Limit history size
116
+ if (this.history.length > 100) {
117
+ this.history.pop();
118
+ }
36
119
  }
37
120
  setAutocompleteProvider(provider) {
38
121
  this.autocompleteProvider = provider;
39
122
  }
123
+ isEditorEmpty() {
124
+ return this.state.lines.length === 1 && this.state.lines[0] === "";
125
+ }
126
+ isOnFirstVisualLine() {
127
+ const visualLines = this.buildVisualLineMap(this.lastWidth);
128
+ const currentVisualLine = this.findCurrentVisualLine(visualLines);
129
+ return currentVisualLine === 0;
130
+ }
131
+ isOnLastVisualLine() {
132
+ const visualLines = this.buildVisualLineMap(this.lastWidth);
133
+ const currentVisualLine = this.findCurrentVisualLine(visualLines);
134
+ return currentVisualLine === visualLines.length - 1;
135
+ }
136
+ navigateHistory(direction) {
137
+ if (this.history.length === 0)
138
+ return;
139
+ const newIndex = this.historyIndex - direction; // Up(-1) increases index, Down(1) decreases
140
+ if (newIndex < -1 || newIndex >= this.history.length)
141
+ return;
142
+ this.historyIndex = newIndex;
143
+ if (this.historyIndex === -1) {
144
+ // Returned to "current" state - clear editor
145
+ this.setTextInternal("");
146
+ }
147
+ else {
148
+ this.setTextInternal(this.history[this.historyIndex] || "");
149
+ }
150
+ }
151
+ /** Internal setText that doesn't reset history state - used by navigateHistory */
152
+ setTextInternal(text) {
153
+ const lines = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n");
154
+ this.state.lines = lines.length === 0 ? [""] : lines;
155
+ this.state.cursorLine = this.state.lines.length - 1;
156
+ this.state.cursorCol = this.state.lines[this.state.cursorLine]?.length || 0;
157
+ if (this.onChange) {
158
+ this.onChange(this.getText());
159
+ }
160
+ }
161
+ invalidate() {
162
+ // No cached state to invalidate currently
163
+ }
40
164
  render(width) {
41
- const horizontal = style.gray("─");
165
+ // Store width for cursor navigation
166
+ this.lastWidth = width;
167
+ const horizontal = this.borderColor("─");
42
168
  // Layout the text - use full width
43
169
  const layoutLines = this.layoutText(width);
44
170
  const result = [];
@@ -47,41 +173,50 @@ export class Editor {
47
173
  // Render each layout line
48
174
  for (const layoutLine of layoutLines) {
49
175
  let displayText = layoutLine.text;
50
- let visibleLength = layoutLine.text.length;
176
+ let lineVisibleWidth = layoutLine.width;
51
177
  // Add cursor if this line has it
52
178
  if (layoutLine.hasCursor && layoutLine.cursorPos !== undefined) {
53
179
  const before = displayText.slice(0, layoutLine.cursorPos);
54
180
  const after = displayText.slice(layoutLine.cursorPos);
55
181
  if (after.length > 0) {
56
- // Cursor is on a character - replace it with highlighted version
57
- const cursor = `\x1b[7m${after[0]}\x1b[0m`;
58
- const restAfter = after.slice(1);
182
+ // Cursor is on a character (grapheme) - replace it with highlighted version
183
+ // Get the first grapheme from 'after'
184
+ const afterGraphemes = [...segmenter.segment(after)];
185
+ const firstGrapheme = afterGraphemes[0]?.segment || "";
186
+ const restAfter = after.slice(firstGrapheme.length);
187
+ const cursor = `\x1b[7m${firstGrapheme}\x1b[0m`;
59
188
  displayText = before + cursor + restAfter;
60
- // visibleLength stays the same - we're replacing, not adding
189
+ // lineVisibleWidth stays the same - we're replacing, not adding
61
190
  }
62
191
  else {
63
192
  // Cursor is at the end - check if we have room for the space
64
- if (layoutLine.text.length < width) {
193
+ if (lineVisibleWidth < width) {
65
194
  // We have room - add highlighted space
66
195
  const cursor = "\x1b[7m \x1b[0m";
67
196
  displayText = before + cursor;
68
- // visibleLength increases by 1 - we're adding a space
69
- visibleLength = layoutLine.text.length + 1;
197
+ // lineVisibleWidth increases by 1 - we're adding a space
198
+ lineVisibleWidth = lineVisibleWidth + 1;
70
199
  }
71
200
  else {
72
- // Line is at full width - use reverse video on last character if possible
201
+ // Line is at full width - use reverse video on last grapheme if possible
73
202
  // or just show cursor at the end without adding space
74
- if (before.length > 0) {
75
- const lastChar = before[before.length - 1];
76
- const cursor = `\x1b[7m${lastChar}\x1b[0m`;
77
- displayText = before.slice(0, -1) + cursor;
203
+ const beforeGraphemes = [...segmenter.segment(before)];
204
+ if (beforeGraphemes.length > 0) {
205
+ const lastGrapheme = beforeGraphemes[beforeGraphemes.length - 1]?.segment || "";
206
+ const cursor = `\x1b[7m${lastGrapheme}\x1b[0m`;
207
+ // Rebuild 'before' without the last grapheme
208
+ const beforeWithoutLast = beforeGraphemes
209
+ .slice(0, -1)
210
+ .map((g) => g.segment)
211
+ .join("");
212
+ displayText = beforeWithoutLast + cursor;
78
213
  }
79
- // visibleLength stays the same
214
+ // lineVisibleWidth stays the same
80
215
  }
81
216
  }
82
217
  }
83
- // Calculate padding based on actual visible length
84
- const padding = " ".repeat(Math.max(0, width - visibleLength));
218
+ // Calculate padding based on actual visible width
219
+ const padding = " ".repeat(Math.max(0, width - lineVisibleWidth));
85
220
  // Render the line (no side borders, just horizontal lines above and below)
86
221
  result.push(displayText + padding);
87
222
  }
@@ -159,45 +294,50 @@ export class Editor {
159
294
  this.cancelAutocomplete();
160
295
  return;
161
296
  }
162
- // Let the autocomplete list handle navigation and selection
163
- if (data === "\x1b[A" ||
164
- data === "\x1b[B" ||
165
- data === "\r" ||
166
- data === "\t") {
167
- // Pass arrow keys and Tab to the list for navigation
168
- if (data === "\x1b[A" || data === "\x1b[B" || data === "\t") {
169
- this.autocompleteList.handleInput(data);
170
- }
171
- // Only Enter applies the selection
172
- if (data === "\r") {
173
- const selected = this.autocompleteList.getSelectedItem();
174
- if (selected && this.autocompleteProvider) {
175
- const result = this.autocompleteProvider.applyCompletion(this.state.lines, this.state.cursorLine, this.state.cursorCol, selected, this.autocompletePrefix);
176
- this.state.lines = result.lines;
177
- this.state.cursorLine = result.cursorLine;
178
- this.state.cursorCol = result.cursorCol;
179
- this.cancelAutocomplete();
180
- if (this.onChange) {
181
- this.onChange(this.getText());
182
- }
297
+ // Enter - apply selection
298
+ if (data === "\r") {
299
+ const selected = this.autocompleteList.getSelectedItem();
300
+ if (selected && this.autocompleteProvider) {
301
+ const result = this.autocompleteProvider.applyCompletion(this.state.lines, this.state.cursorLine, this.state.cursorCol, selected, this.autocompletePrefix);
302
+ this.state.lines = result.lines;
303
+ this.state.cursorLine = result.cursorLine;
304
+ this.state.cursorCol = result.cursorCol;
305
+ this.cancelAutocomplete();
306
+ if (this.onChange) {
307
+ this.onChange(this.getText());
183
308
  }
184
- return;
185
309
  }
186
- // For other keys, handle normally within autocomplete
310
+ return;
311
+ }
312
+ // Navigation keys (arrows, Tab, Shift+Tab) - pass to autocomplete list
313
+ if (isNavigationKey(data)) {
314
+ this.autocompleteList.handleInput(data);
187
315
  return;
188
316
  }
189
317
  // For other keys (like regular typing), DON'T return here
190
318
  // Let them fall through to normal character handling
191
319
  }
192
320
  // Tab key - context-aware completion (but not when already autocompleting)
193
- if (data === "\t" && !this.isAutocompleting) {
321
+ if (isTab(data) && !this.isAutocompleting) {
194
322
  void this.handleTabCompletion();
195
323
  return;
196
324
  }
197
325
  // Continue with rest of input handling
198
- // Ctrl+K - Delete current line
326
+ // Ctrl+K - Delete to end of line
199
327
  if (data.charCodeAt(0) === 11) {
200
- this.deleteCurrentLine();
328
+ this.deleteToEndOfLine();
329
+ }
330
+ // Ctrl+U - Delete to start of line
331
+ else if (data.charCodeAt(0) === 21) {
332
+ this.deleteToStartOfLine();
333
+ }
334
+ // Ctrl+W - Delete word backwards
335
+ else if (data.charCodeAt(0) === 23) {
336
+ this.deleteWordBackwards();
337
+ }
338
+ // Option/Alt+Backspace (e.g. Ghostty sends ESC + DEL)
339
+ else if (data === "\x1b\x7f") {
340
+ this.deleteWordBackwards();
201
341
  }
202
342
  // Ctrl+A - Move to start of line
203
343
  else if (data.charCodeAt(0) === 1) {
@@ -207,13 +347,12 @@ export class Editor {
207
347
  else if (data.charCodeAt(0) === 5) {
208
348
  this.moveToLineEnd();
209
349
  }
210
- // Modified Enter keys (Shift+Enter, Ctrl+Enter, etc.) - create new line
211
- else if (this.isModifiedEnter(data)) {
212
- // Modifier + Enter = new line
350
+ // Plain Enter (char code 13 for CR) - create new line
351
+ else if (data.charCodeAt(0) === 13 && data.length === 1) {
213
352
  this.addNewLine();
214
353
  }
215
- // Plain Enter (char code 13 for CR) - only CR submits, LF adds new line
216
- else if (data.charCodeAt(0) === 13 && data.length === 1) {
354
+ // Modified Enter keys (Shift+Enter, Ctrl+Enter, etc.) - submit
355
+ else if (this.isModifiedEnter(data)) {
217
356
  // If submit is disabled, do nothing
218
357
  if (this.disableSubmit) {
219
358
  return;
@@ -234,6 +373,7 @@ export class Editor {
234
373
  };
235
374
  this.pastes.clear();
236
375
  this.pasteCounter = 0;
376
+ this.historyIndex = -1; // Exit history browsing mode
237
377
  // Notify that editor is now empty
238
378
  if (this.onChange) {
239
379
  this.onChange("");
@@ -260,14 +400,42 @@ export class Editor {
260
400
  // Delete key
261
401
  this.handleForwardDelete();
262
402
  }
403
+ // Word navigation (Option/Alt + Arrow or Ctrl + Arrow)
404
+ // Option+Left: \x1b[1;3D or \x1bb
405
+ // Option+Right: \x1b[1;3C or \x1bf
406
+ // Ctrl+Left: \x1b[1;5D
407
+ // Ctrl+Right: \x1b[1;5C
408
+ else if (data === "\x1b[1;3D" || data === "\x1bb" || data === "\x1b[1;5D") {
409
+ // Word left
410
+ this.moveWordBackwards();
411
+ }
412
+ else if (data === "\x1b[1;3C" ||
413
+ data === "\x1bf" ||
414
+ data === "\x1b[1;5C") {
415
+ // Word right
416
+ this.moveWordForwards();
417
+ }
263
418
  // Arrow keys
264
419
  else if (data === "\x1b[A") {
265
- // Up
266
- this.moveCursor(-1, 0);
420
+ // Up - history navigation or cursor movement
421
+ if (this.isEditorEmpty()) {
422
+ this.navigateHistory(-1); // Start browsing history
423
+ }
424
+ else if (this.historyIndex > -1 && this.isOnFirstVisualLine()) {
425
+ this.navigateHistory(-1); // Navigate to older history entry
426
+ }
427
+ else {
428
+ this.moveCursor(-1, 0); // Cursor movement (within text or history entry)
429
+ }
267
430
  }
268
431
  else if (data === "\x1b[B") {
269
- // Down
270
- this.moveCursor(1, 0);
432
+ // Down - history navigation or cursor movement
433
+ if (this.historyIndex > -1 && this.isOnLastVisualLine()) {
434
+ this.navigateHistory(1); // Navigate to newer history entry or clear
435
+ }
436
+ else {
437
+ this.moveCursor(1, 0); // Cursor movement (within text or history entry)
438
+ }
271
439
  }
272
440
  else if (data === "\x1b[C") {
273
441
  // Right
@@ -277,8 +445,8 @@ export class Editor {
277
445
  // Left
278
446
  this.moveCursor(0, -1);
279
447
  }
280
- // Regular characters (printable ASCII)
281
- else if (data.charCodeAt(0) >= 32 && data.charCodeAt(0) <= 126) {
448
+ // Regular characters (printable characters and unicode, but not control characters)
449
+ else if (data.charCodeAt(0) >= 32) {
282
450
  this.insertCharacter(data);
283
451
  }
284
452
  }
@@ -291,6 +459,7 @@ export class Editor {
291
459
  text: "",
292
460
  hasCursor: true,
293
461
  cursorPos: 0,
462
+ width: 0,
294
463
  });
295
464
  return layoutLines;
296
465
  }
@@ -298,48 +467,89 @@ export class Editor {
298
467
  for (let i = 0; i < this.state.lines.length; i++) {
299
468
  const line = this.state.lines[i] || "";
300
469
  const isCurrentLine = i === this.state.cursorLine;
301
- const maxLineLength = contentWidth;
302
- if (line.length <= maxLineLength) {
470
+ const metrics = lineMetricsCache.get(line);
471
+ const lineVisibleWidth = metrics.totalWidth;
472
+ if (lineVisibleWidth <= contentWidth) {
303
473
  // Line fits in one layout line
304
474
  if (isCurrentLine) {
305
475
  layoutLines.push({
306
476
  text: line,
307
477
  hasCursor: true,
308
478
  cursorPos: this.state.cursorCol,
479
+ width: lineVisibleWidth,
309
480
  });
310
481
  }
311
482
  else {
312
483
  layoutLines.push({
313
484
  text: line,
314
485
  hasCursor: false,
486
+ width: lineVisibleWidth,
315
487
  });
316
488
  }
317
489
  }
318
490
  else {
319
- // Line needs wrapping
491
+ // Line needs wrapping - use cached graphemes and widths
320
492
  const chunks = [];
321
- for (let pos = 0; pos < line.length; pos += maxLineLength) {
322
- chunks.push(line.slice(pos, pos + maxLineLength));
493
+ let currentChunk = "";
494
+ let currentWidth = 0;
495
+ let chunkStartIndex = 0;
496
+ let currentIndex = 0;
497
+ for (let g = 0; g < metrics.graphemes.length; g++) {
498
+ const grapheme = metrics.graphemes[g];
499
+ const graphemeWidth = metrics.widths[g];
500
+ if (currentWidth + graphemeWidth > contentWidth &&
501
+ currentChunk !== "") {
502
+ // Start a new chunk
503
+ chunks.push({
504
+ text: currentChunk,
505
+ startIndex: chunkStartIndex,
506
+ endIndex: currentIndex,
507
+ width: currentWidth,
508
+ });
509
+ currentChunk = grapheme;
510
+ currentWidth = graphemeWidth;
511
+ chunkStartIndex = currentIndex;
512
+ }
513
+ else {
514
+ currentChunk += grapheme;
515
+ currentWidth += graphemeWidth;
516
+ }
517
+ currentIndex += grapheme.length;
518
+ }
519
+ // Push the last chunk
520
+ if (currentChunk !== "") {
521
+ chunks.push({
522
+ text: currentChunk,
523
+ startIndex: chunkStartIndex,
524
+ endIndex: currentIndex,
525
+ width: currentWidth,
526
+ });
323
527
  }
324
528
  for (let chunkIndex = 0; chunkIndex < chunks.length; chunkIndex++) {
325
529
  const chunk = chunks[chunkIndex];
326
530
  if (!chunk)
327
531
  continue;
328
- const chunkStart = chunkIndex * maxLineLength;
329
- const chunkEnd = chunkStart + chunk.length;
330
532
  const cursorPos = this.state.cursorCol;
331
- const hasCursorInChunk = isCurrentLine && cursorPos >= chunkStart && cursorPos <= chunkEnd;
533
+ const isLastChunk = chunkIndex === chunks.length - 1;
534
+ // For non-last chunks, cursor at endIndex belongs to the next chunk
535
+ const hasCursorInChunk = isCurrentLine &&
536
+ cursorPos >= chunk.startIndex &&
537
+ (isLastChunk
538
+ ? cursorPos <= chunk.endIndex
539
+ : cursorPos < chunk.endIndex);
332
540
  if (hasCursorInChunk) {
333
541
  layoutLines.push({
334
- text: chunk,
542
+ text: chunk.text,
335
543
  hasCursor: true,
336
- cursorPos: cursorPos - chunkStart,
544
+ cursorPos: cursorPos - chunk.startIndex,
545
+ width: chunk.width,
337
546
  });
338
547
  }
339
548
  else {
340
549
  layoutLines.push({
341
- text: chunk,
550
+ text: chunk.text,
342
551
  hasCursor: false,
552
+ width: chunk.width,
343
553
  });
344
554
  }
345
555
  }
@@ -351,20 +561,12 @@ export class Editor {
351
561
  return this.state.lines.join("\n");
352
562
  }
353
563
  setText(text) {
354
- // Split text into lines, handling different line endings
355
- const lines = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n");
356
- // Ensure at least one empty line
357
- this.state.lines = lines.length === 0 ? [""] : lines;
358
- // Reset cursor to end of text
359
- this.state.cursorLine = this.state.lines.length - 1;
360
- this.state.cursorCol = this.state.lines[this.state.cursorLine]?.length || 0;
361
- // Notify of change
362
- if (this.onChange) {
363
- this.onChange(this.getText());
364
- }
564
+ this.historyIndex = -1; // Exit history browsing mode
565
+ this.setTextInternal(text);
365
566
  }
366
567
  // All the editor methods from before...
367
568
  insertCharacter(char) {
569
+ this.historyIndex = -1; // Exit history browsing mode
368
570
  const line = this.state.lines[this.state.cursorLine] || "";
369
571
  const before = line.slice(0, this.state.cursorCol);
370
572
  const after = line.slice(this.state.cursorCol);
@@ -379,13 +581,28 @@ export class Editor {
379
581
  if (char === "/" && this.isAtStartOfMessage()) {
380
582
  void this.tryTriggerAutocomplete();
381
583
  }
584
+ // Auto-trigger for "@" file reference (fuzzy search)
585
+ else if (char === "@") {
586
+ const currentLine = this.state.lines[this.state.cursorLine] || "";
587
+ const textBeforeCursor = currentLine.slice(0, this.state.cursorCol);
588
+ // Only trigger if @ is after whitespace or at start of line
589
+ const charBeforeAt = textBeforeCursor[textBeforeCursor.length - 2];
590
+ if (textBeforeCursor.length === 1 ||
591
+ charBeforeAt === " " ||
592
+ charBeforeAt === "\t") {
593
+ void this.tryTriggerAutocomplete();
594
+ }
595
+ }
382
596
  // Also auto-trigger when typing letters in a slash command context
383
597
  else if (/[a-zA-Z0-9]/.test(char)) {
384
598
  const currentLine = this.state.lines[this.state.cursorLine] || "";
385
599
  const textBeforeCursor = currentLine.slice(0, this.state.cursorCol);
386
- // Check if we're in a slash command with a space (i.e., typing arguments)
387
- if (textBeforeCursor.startsWith("/") &&
388
- textBeforeCursor.includes(" ")) {
600
+ // Check if we're in a slash command (with or without space for arguments)
601
+ if (textBeforeCursor.trimStart().startsWith("/")) {
602
+ void this.tryTriggerAutocomplete();
603
+ }
604
+ // Check if we're in an @ file reference context
605
+ else if (textBeforeCursor.match(/(?:^|[\s])@[^\s]*$/)) {
389
606
  void this.tryTriggerAutocomplete();
390
607
  }
391
608
  }
@@ -501,6 +718,15 @@ export class Editor {
501
718
  if (this.isAutocompleting) {
502
719
  void this.updateAutocomplete();
503
720
  }
721
+ else {
722
+ // Check if we should trigger autocomplete after backspace in slash command context
723
+ const currentLine = this.state.lines[this.state.cursorLine] || "";
724
+ const textBeforeCursor = currentLine.slice(0, this.state.cursorCol);
725
+ // Trigger autocomplete if we're in a slash command context (typing command name)
726
+ if (textBeforeCursor.startsWith("/") && !textBeforeCursor.includes(" ")) {
727
+ void this.tryTriggerAutocomplete();
728
+ }
729
+ }
504
730
  }
505
731
  moveToLineStart() {
506
732
  this.state.cursorCol = 0;
@@ -527,45 +753,276 @@ export class Editor {
527
753
  this.onChange(this.getText());
528
754
  }
529
755
  }
530
- deleteCurrentLine() {
531
- if (this.state.lines.length === 1) {
532
- // Only one line - just clear it
533
- this.state.lines[0] = "";
756
+ deleteToStartOfLine() {
757
+ this.historyIndex = -1; // Exit history browsing mode
758
+ const currentLine = this.state.lines[this.state.cursorLine] || "";
759
+ if (this.state.cursorCol > 0) {
760
+ // Delete from start of line up to cursor
761
+ this.state.lines[this.state.cursorLine] = currentLine.slice(this.state.cursorCol);
534
762
  this.state.cursorCol = 0;
535
763
  }
536
- else {
537
- // Multiple lines - remove current line
764
+ else if (this.state.cursorLine > 0) {
765
+ // At start of line - merge with previous line
766
+ const previousLine = this.state.lines[this.state.cursorLine - 1] || "";
767
+ this.state.lines[this.state.cursorLine - 1] = previousLine + currentLine;
538
768
  this.state.lines.splice(this.state.cursorLine, 1);
539
- // Adjust cursor position
540
- if (this.state.cursorLine >= this.state.lines.length) {
541
- // Was on last line, move to new last line
542
- this.state.cursorLine = this.state.lines.length - 1;
769
+ this.state.cursorLine--;
770
+ this.state.cursorCol = previousLine.length;
771
+ }
772
+ if (this.onChange) {
773
+ this.onChange(this.getText());
774
+ }
775
+ }
776
+ deleteToEndOfLine() {
777
+ this.historyIndex = -1; // Exit history browsing mode
778
+ const currentLine = this.state.lines[this.state.cursorLine] || "";
779
+ if (this.state.cursorCol < currentLine.length) {
780
+ // Delete from cursor to end of line
781
+ this.state.lines[this.state.cursorLine] = currentLine.slice(0, this.state.cursorCol);
782
+ }
783
+ else if (this.state.cursorLine < this.state.lines.length - 1) {
784
+ // At end of line - merge with next line
785
+ const nextLine = this.state.lines[this.state.cursorLine + 1] || "";
786
+ this.state.lines[this.state.cursorLine] = currentLine + nextLine;
787
+ this.state.lines.splice(this.state.cursorLine + 1, 1);
788
+ }
789
+ if (this.onChange) {
790
+ this.onChange(this.getText());
791
+ }
792
+ }
793
+ deleteWordBackwards() {
794
+ this.historyIndex = -1; // Exit history browsing mode
795
+ const currentLine = this.state.lines[this.state.cursorLine] || "";
796
+ // If at start of line, behave like backspace at column 0 (merge with previous line)
797
+ if (this.state.cursorCol === 0) {
798
+ if (this.state.cursorLine > 0) {
799
+ const previousLine = this.state.lines[this.state.cursorLine - 1] || "";
800
+ this.state.lines[this.state.cursorLine - 1] =
801
+ previousLine + currentLine;
802
+ this.state.lines.splice(this.state.cursorLine, 1);
803
+ this.state.cursorLine--;
804
+ this.state.cursorCol = previousLine.length;
543
805
  }
544
- // Clamp cursor column to new line length
545
- const newLine = this.state.lines[this.state.cursorLine] || "";
546
- this.state.cursorCol = Math.min(this.state.cursorCol, newLine.length);
806
+ }
807
+ else {
808
+ const textBeforeCursor = currentLine.slice(0, this.state.cursorCol);
809
+ const isWhitespace = (char) => /\s/.test(char);
810
+ const isPunctuation = (char) => {
811
+ // Treat obvious code punctuation as boundaries
812
+ return /[(){}[\]<>.,;:'"!?+\-=*/\\|&%^$#@~`]/.test(char);
813
+ };
814
+ let deleteFrom = this.state.cursorCol;
815
+ const lastChar = textBeforeCursor[deleteFrom - 1] ?? "";
816
+ // If immediately on whitespace or punctuation, delete that single boundary char
817
+ if (isWhitespace(lastChar) || isPunctuation(lastChar)) {
818
+ deleteFrom -= 1;
819
+ }
820
+ else {
821
+ // Otherwise, delete a run of non-boundary characters (the "word")
822
+ while (deleteFrom > 0) {
823
+ const ch = textBeforeCursor[deleteFrom - 1] ?? "";
824
+ if (isWhitespace(ch) || isPunctuation(ch)) {
825
+ break;
826
+ }
827
+ deleteFrom -= 1;
828
+ }
829
+ }
830
+ this.state.lines[this.state.cursorLine] =
831
+ currentLine.slice(0, deleteFrom) +
832
+ currentLine.slice(this.state.cursorCol);
833
+ this.state.cursorCol = deleteFrom;
547
834
  }
548
835
  if (this.onChange) {
549
836
  this.onChange(this.getText());
550
837
  }
551
838
  }
839
+ /**
840
+ * Build a mapping from visual lines to logical positions.
841
+ * Returns an array where each element represents a visual line with:
842
+ * - logicalLine: index into this.state.lines
843
+ * - startCol: starting column in the logical line
844
+ * - length: length of this visual line segment
845
+ */
846
+ buildVisualLineMap(width) {
847
+ const visualLines = [];
848
+ for (let i = 0; i < this.state.lines.length; i++) {
849
+ const line = this.state.lines[i] || "";
850
+ const metrics = lineMetricsCache.get(line);
851
+ const lineVisWidth = metrics.totalWidth;
852
+ if (line.length === 0) {
853
+ // Empty line still takes one visual line
854
+ visualLines.push({ logicalLine: i, startCol: 0, length: 0 });
855
+ }
856
+ else if (lineVisWidth <= width) {
857
+ visualLines.push({ logicalLine: i, startCol: 0, length: line.length });
858
+ }
859
+ else {
860
+ // Line needs wrapping - use cached graphemes and widths
861
+ let currentWidth = 0;
862
+ let chunkStartIndex = 0;
863
+ let currentIndex = 0;
864
+ for (let g = 0; g < metrics.graphemes.length; g++) {
865
+ const grapheme = metrics.graphemes[g];
866
+ const graphemeWidth = metrics.widths[g];
867
+ if (currentWidth + graphemeWidth > width &&
868
+ currentIndex > chunkStartIndex) {
869
+ // Start a new chunk
870
+ visualLines.push({
871
+ logicalLine: i,
872
+ startCol: chunkStartIndex,
873
+ length: currentIndex - chunkStartIndex,
874
+ });
875
+ chunkStartIndex = currentIndex;
876
+ currentWidth = graphemeWidth;
877
+ }
878
+ else {
879
+ currentWidth += graphemeWidth;
880
+ }
881
+ currentIndex += grapheme.length;
882
+ }
883
+ // Push the last chunk
884
+ if (currentIndex > chunkStartIndex) {
885
+ visualLines.push({
886
+ logicalLine: i,
887
+ startCol: chunkStartIndex,
888
+ length: currentIndex - chunkStartIndex,
889
+ });
890
+ }
891
+ }
892
+ }
893
+ return visualLines;
894
+ }
895
+ /**
896
+ * Find the visual line index for the current cursor position.
897
+ */
898
+ findCurrentVisualLine(visualLines) {
899
+ for (let i = 0; i < visualLines.length; i++) {
900
+ const vl = visualLines[i];
901
+ if (!vl)
902
+ continue;
903
+ if (vl.logicalLine === this.state.cursorLine) {
904
+ const colInSegment = this.state.cursorCol - vl.startCol;
905
+ // Cursor is in this segment if it's within range
906
+ // For the last segment of a logical line, cursor can be at length (end position)
907
+ const isLastSegmentOfLine = i === visualLines.length - 1 ||
908
+ visualLines[i + 1]?.logicalLine !== vl.logicalLine;
909
+ if (colInSegment >= 0 &&
910
+ (colInSegment < vl.length ||
911
+ (isLastSegmentOfLine && colInSegment <= vl.length))) {
912
+ return i;
913
+ }
914
+ }
915
+ }
916
+ // Fallback: return last visual line
917
+ return visualLines.length - 1;
918
+ }
552
919
  moveCursor(deltaLine, deltaCol) {
920
+ const width = this.lastWidth;
553
921
  if (deltaLine !== 0) {
554
- const newLine = this.state.cursorLine + deltaLine;
555
- if (newLine >= 0 && newLine < this.state.lines.length) {
556
- this.state.cursorLine = newLine;
557
- // Clamp cursor column to new line length
558
- const line = this.state.lines[this.state.cursorLine] || "";
559
- this.state.cursorCol = Math.min(this.state.cursorCol, line.length);
922
+ // Build visual line map for navigation
923
+ const visualLines = this.buildVisualLineMap(width);
924
+ const currentVisualLine = this.findCurrentVisualLine(visualLines);
925
+ // Calculate column position within current visual line
926
+ const currentVl = visualLines[currentVisualLine];
927
+ const visualCol = currentVl
928
+ ? this.state.cursorCol - currentVl.startCol
929
+ : 0;
930
+ // Move to target visual line
931
+ const targetVisualLine = currentVisualLine + deltaLine;
932
+ if (targetVisualLine >= 0 && targetVisualLine < visualLines.length) {
933
+ const targetVl = visualLines[targetVisualLine];
934
+ if (targetVl) {
935
+ this.state.cursorLine = targetVl.logicalLine;
936
+ // Try to maintain visual column position, clamped to line length
937
+ const targetCol = targetVl.startCol + Math.min(visualCol, targetVl.length);
938
+ const logicalLine = this.state.lines[targetVl.logicalLine] || "";
939
+ this.state.cursorCol = Math.min(targetCol, logicalLine.length);
940
+ }
560
941
  }
561
942
  }
562
943
  if (deltaCol !== 0) {
563
- // Move column
564
- const newCol = this.state.cursorCol + deltaCol;
565
944
  const currentLine = this.state.lines[this.state.cursorLine] || "";
566
- const maxCol = currentLine.length;
567
- this.state.cursorCol = Math.max(0, Math.min(maxCol, newCol));
945
+ if (deltaCol > 0) {
946
+ // Moving right
947
+ if (this.state.cursorCol < currentLine.length) {
948
+ this.state.cursorCol++;
949
+ }
950
+ else if (this.state.cursorLine < this.state.lines.length - 1) {
951
+ // Wrap to start of next logical line
952
+ this.state.cursorLine++;
953
+ this.state.cursorCol = 0;
954
+ }
955
+ }
956
+ else {
957
+ // Moving left
958
+ if (this.state.cursorCol > 0) {
959
+ this.state.cursorCol--;
960
+ }
961
+ else if (this.state.cursorLine > 0) {
962
+ // Wrap to end of previous logical line
963
+ this.state.cursorLine--;
964
+ const prevLine = this.state.lines[this.state.cursorLine] || "";
965
+ this.state.cursorCol = prevLine.length;
966
+ }
967
+ }
968
+ }
969
+ }
970
+ isWordBoundary(char) {
971
+ return /\s/.test(char) || /[(){}[\]<>.,;:'"!?+\-=*/\\|&%^$#@~`]/.test(char);
972
+ }
973
+ moveWordBackwards() {
974
+ const currentLine = this.state.lines[this.state.cursorLine] || "";
975
+ // If at start of line, move to end of previous line
976
+ if (this.state.cursorCol === 0) {
977
+ if (this.state.cursorLine > 0) {
978
+ this.state.cursorLine--;
979
+ const prevLine = this.state.lines[this.state.cursorLine] || "";
980
+ this.state.cursorCol = prevLine.length;
981
+ }
982
+ return;
983
+ }
984
+ const textBeforeCursor = currentLine.slice(0, this.state.cursorCol);
985
+ let newCol = this.state.cursorCol;
986
+ const lastChar = textBeforeCursor[newCol - 1] ?? "";
987
+ // If immediately on whitespace or punctuation, skip that single boundary char
988
+ if (this.isWordBoundary(lastChar)) {
989
+ newCol -= 1;
990
+ }
991
+ // Now skip the "word" (non-boundary characters)
992
+ while (newCol > 0) {
993
+ const ch = textBeforeCursor[newCol - 1] ?? "";
994
+ if (this.isWordBoundary(ch)) {
995
+ break;
996
+ }
997
+ newCol -= 1;
998
+ }
999
+ this.state.cursorCol = newCol;
1000
+ }
1001
+ moveWordForwards() {
1002
+ const currentLine = this.state.lines[this.state.cursorLine] || "";
1003
+ // If at end of line, move to start of next line
1004
+ if (this.state.cursorCol >= currentLine.length) {
1005
+ if (this.state.cursorLine < this.state.lines.length - 1) {
1006
+ this.state.cursorLine++;
1007
+ this.state.cursorCol = 0;
1008
+ }
1009
+ return;
1010
+ }
1011
+ let newCol = this.state.cursorCol;
1012
+ const charAtCursor = currentLine[newCol] ?? "";
1013
+ // If on whitespace or punctuation, skip it
1014
+ if (this.isWordBoundary(charAtCursor)) {
1015
+ newCol += 1;
1016
+ }
1017
+ // Skip the "word" (non-boundary characters)
1018
+ while (newCol < currentLine.length) {
1019
+ const ch = currentLine[newCol] ?? "";
1020
+ if (this.isWordBoundary(ch)) {
1021
+ break;
1022
+ }
1023
+ newCol += 1;
568
1024
  }
1025
+ this.state.cursorCol = newCol;
569
1026
  }
570
1027
  // Helper method to check if cursor is at start of message (for slash command detection)
571
1028
  isAtStartOfMessage() {
@@ -597,7 +1054,7 @@ export class Editor {
597
1054
  this.autocompleteList.updateItems(suggestions.items);
598
1055
  }
599
1056
  else {
600
- this.autocompleteList = new SelectList(suggestions.items, 5);
1057
+ this.autocompleteList = new SelectList(suggestions.items, 5, this.theme.selectList);
601
1058
  }
602
1059
  // Request re-render to show autocomplete list
603
1060
  this.onRenderRequested?.();
@@ -640,7 +1097,7 @@ export class Editor {
640
1097
  this.autocompleteList.updateItems(suggestions.items);
641
1098
  }
642
1099
  else {
643
- this.autocompleteList = new SelectList(suggestions.items, 5);
1100
+ this.autocompleteList = new SelectList(suggestions.items, 5, this.theme.selectList);
644
1101
  }
645
1102
  this.isAutocompleting = true;
646
1103
  // Request re-render to show autocomplete list
@@ -697,7 +1154,7 @@ export class Editor {
697
1154
  this.autocompleteList.updateItems(suggestions.items);
698
1155
  }
699
1156
  else {
700
- this.autocompleteList = new SelectList(suggestions.items, 5);
1157
+ this.autocompleteList = new SelectList(suggestions.items, 5, this.theme.selectList);
701
1158
  }
702
1159
  this.isAutocompleting = true;
703
1160
  // Request re-render to show updated autocomplete list
@@ -719,8 +1176,12 @@ export class Editor {
719
1176
  "\x1bOM", // Some terminals
720
1177
  "\\\r", // VS Code terminal
721
1178
  "\x1b\r", // Option+Enter (macOS)
1179
+ "\x1b[27;2;13~", // xterm shift+enter
1180
+ "\x1b[13;2u", // libtermkey shift+enter
722
1181
  // Ctrl+Enter sequences
723
1182
  "\x1b[13;5~", // Some terminals
1183
+ "\x1b[27;5;13~", // xterm ctrl+enter
1184
+ "\x1b[13;5u", // libtermkey ctrl+enter
724
1185
  ];
725
1186
  // Check for known sequences
726
1187
  if (sequences.includes(data)) {
@@ -732,8 +1193,9 @@ export class Editor {
732
1193
  (data.includes("\r") || data.includes("\n"))) {
733
1194
  return true;
734
1195
  }
735
- // Check for Ctrl+Enter (Ctrl + CR)
736
- if (data.charCodeAt(0) === 13 && data.length > 1) {
1196
+ // Check for Ctrl+Enter (Ctrl + CR) or Ctrl+Enter with LF
1197
+ if ((data.charCodeAt(0) === 13 || data.charCodeAt(0) === 10) &&
1198
+ data.length > 1) {
737
1199
  return true;
738
1200
  }
739
1201
  return false;