@dungle-scrubs/tallow 0.9.4 → 0.9.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 (212) hide show
  1. package/dist/cli.js +8 -5
  2. package/dist/cli.js.map +1 -1
  3. package/dist/config.d.ts +1 -1
  4. package/dist/config.js +1 -1
  5. package/dist/interactive-mode-patch.d.ts +24 -12
  6. package/dist/interactive-mode-patch.d.ts.map +1 -1
  7. package/dist/interactive-mode-patch.js +229 -146
  8. package/dist/interactive-mode-patch.js.map +1 -1
  9. package/dist/interactive-reset.d.ts +49 -0
  10. package/dist/interactive-reset.d.ts.map +1 -0
  11. package/dist/interactive-reset.js +40 -0
  12. package/dist/interactive-reset.js.map +1 -0
  13. package/dist/pi-tui-editor-patch.d.ts +10 -0
  14. package/dist/pi-tui-editor-patch.d.ts.map +1 -0
  15. package/dist/pi-tui-editor-patch.js +159 -0
  16. package/dist/pi-tui-editor-patch.js.map +1 -0
  17. package/dist/pi-tui-patch.d.ts +2 -0
  18. package/dist/pi-tui-patch.d.ts.map +1 -0
  19. package/dist/pi-tui-patch.js +563 -0
  20. package/dist/pi-tui-patch.js.map +1 -0
  21. package/dist/pi-tui-settings-list-patch.d.ts +11 -0
  22. package/dist/pi-tui-settings-list-patch.d.ts.map +1 -0
  23. package/dist/pi-tui-settings-list-patch.js +38 -0
  24. package/dist/pi-tui-settings-list-patch.js.map +1 -0
  25. package/dist/process-cleanup.js +1 -1
  26. package/dist/process-cleanup.js.map +1 -1
  27. package/dist/reset-diagnostics.d.ts +69 -0
  28. package/dist/reset-diagnostics.d.ts.map +1 -0
  29. package/dist/reset-diagnostics.js +41 -0
  30. package/dist/reset-diagnostics.js.map +1 -0
  31. package/dist/sdk.d.ts +7 -23
  32. package/dist/sdk.d.ts.map +1 -1
  33. package/dist/sdk.js +211 -174
  34. package/dist/sdk.js.map +1 -1
  35. package/dist/workspace-transition-interactive.d.ts +1 -0
  36. package/dist/workspace-transition-interactive.d.ts.map +1 -1
  37. package/dist/workspace-transition-interactive.js +8 -18
  38. package/dist/workspace-transition-interactive.js.map +1 -1
  39. package/extensions/__integration__/audit-findings.test.ts +4 -5
  40. package/extensions/_icons/index.ts +2 -4
  41. package/extensions/_shared/__tests__/image-metadata.test.ts +33 -0
  42. package/extensions/_shared/__tests__/shell-policy.test.ts +19 -0
  43. package/extensions/_shared/__tests__/terminal-links.test.ts +18 -0
  44. package/extensions/_shared/image-metadata.ts +99 -0
  45. package/extensions/_shared/inline-preview.ts +1 -1
  46. package/extensions/_shared/shell-policy.ts +121 -1
  47. package/extensions/_shared/terminal-links.ts +22 -0
  48. package/extensions/ask-user-question-tool/index.ts +0 -3
  49. package/extensions/clear/__tests__/clear.test.ts +269 -2
  50. package/extensions/command-expansion/index.ts +9 -3
  51. package/extensions/context-files/index.ts +5 -1
  52. package/extensions/context-fork/__tests__/context-fork.test.ts +94 -1
  53. package/extensions/context-fork/extension.json +1 -1
  54. package/extensions/context-fork/frontmatter-index.ts +6 -1
  55. package/extensions/context-fork/index.ts +32 -0
  56. package/extensions/edit-tool-enhanced/index.ts +2 -1
  57. package/extensions/git-status/__tests__/git-status.test.ts +65 -2
  58. package/extensions/git-status/index.ts +268 -98
  59. package/extensions/hooks/index.ts +33 -11
  60. package/extensions/loop/index.ts +14 -1
  61. package/extensions/lsp/index.ts +64 -13
  62. package/extensions/lsp/package.json +2 -2
  63. package/extensions/minimal-skill-display/index.ts +7 -1
  64. package/extensions/random-spinner/index.ts +7 -642
  65. package/extensions/read-tool-enhanced/index.ts +13 -10
  66. package/extensions/render-stabilizer/__tests__/render-stabilizer.test.ts +2 -3
  67. package/extensions/render-stabilizer/index.ts +6 -6
  68. package/extensions/rewind/__tests__/session-files.test.ts +115 -0
  69. package/extensions/rewind/__tests__/snapshots.test.ts +23 -0
  70. package/extensions/rewind/index.ts +5 -0
  71. package/extensions/rewind/session-files.ts +138 -0
  72. package/extensions/rewind/snapshots.ts +104 -5
  73. package/extensions/skill-commands/index.ts +6 -1
  74. package/extensions/slash-command-bridge/__tests__/slash-command-bridge.test.ts +26 -0
  75. package/extensions/slash-command-bridge/index.ts +14 -2
  76. package/extensions/subagent-tool/model-resolver.ts +274 -7
  77. package/extensions/subagent-tool/schema.ts +1 -2
  78. package/extensions/tasks/commands/register-tasks-extension.ts +9 -9
  79. package/extensions/teams-tool/tools/register-extension.ts +1 -3
  80. package/extensions/teams-tool/tools/teammate-tools.ts +1 -2
  81. package/extensions/web-search-tool/index.ts +2 -1
  82. package/extensions/wezterm-pane-control/index.ts +1 -2
  83. package/extensions/write-tool-enhanced/index.ts +2 -1
  84. package/node_modules/@mariozechner/pi-tui/README.md +56 -34
  85. package/node_modules/@mariozechner/pi-tui/dist/autocomplete.d.ts +18 -13
  86. package/node_modules/@mariozechner/pi-tui/dist/autocomplete.d.ts.map +1 -1
  87. package/node_modules/@mariozechner/pi-tui/dist/autocomplete.js +182 -113
  88. package/node_modules/@mariozechner/pi-tui/dist/autocomplete.js.map +1 -1
  89. package/node_modules/@mariozechner/pi-tui/dist/components/cancellable-loader.js +3 -3
  90. package/node_modules/@mariozechner/pi-tui/dist/components/cancellable-loader.js.map +1 -1
  91. package/node_modules/@mariozechner/pi-tui/dist/components/editor.d.ts +45 -36
  92. package/node_modules/@mariozechner/pi-tui/dist/components/editor.d.ts.map +1 -1
  93. package/node_modules/@mariozechner/pi-tui/dist/components/editor.js +489 -325
  94. package/node_modules/@mariozechner/pi-tui/dist/components/editor.js.map +1 -1
  95. package/node_modules/@mariozechner/pi-tui/dist/components/image.d.ts +1 -99
  96. package/node_modules/@mariozechner/pi-tui/dist/components/image.d.ts.map +1 -1
  97. package/node_modules/@mariozechner/pi-tui/dist/components/image.js +17 -192
  98. package/node_modules/@mariozechner/pi-tui/dist/components/image.js.map +1 -1
  99. package/node_modules/@mariozechner/pi-tui/dist/components/input.d.ts.map +1 -1
  100. package/node_modules/@mariozechner/pi-tui/dist/components/input.js +57 -60
  101. package/node_modules/@mariozechner/pi-tui/dist/components/input.js.map +1 -1
  102. package/node_modules/@mariozechner/pi-tui/dist/components/loader.d.ts +2 -69
  103. package/node_modules/@mariozechner/pi-tui/dist/components/loader.d.ts.map +1 -1
  104. package/node_modules/@mariozechner/pi-tui/dist/components/loader.js +5 -102
  105. package/node_modules/@mariozechner/pi-tui/dist/components/loader.js.map +1 -1
  106. package/node_modules/@mariozechner/pi-tui/dist/components/markdown.d.ts.map +1 -1
  107. package/node_modules/@mariozechner/pi-tui/dist/components/markdown.js +111 -53
  108. package/node_modules/@mariozechner/pi-tui/dist/components/markdown.js.map +1 -1
  109. package/node_modules/@mariozechner/pi-tui/dist/components/select-list.d.ts +19 -1
  110. package/node_modules/@mariozechner/pi-tui/dist/components/select-list.d.ts.map +1 -1
  111. package/node_modules/@mariozechner/pi-tui/dist/components/select-list.js +78 -67
  112. package/node_modules/@mariozechner/pi-tui/dist/components/select-list.js.map +1 -1
  113. package/node_modules/@mariozechner/pi-tui/dist/components/settings-list.d.ts +1 -25
  114. package/node_modules/@mariozechner/pi-tui/dist/components/settings-list.d.ts.map +1 -1
  115. package/node_modules/@mariozechner/pi-tui/dist/components/settings-list.js +13 -50
  116. package/node_modules/@mariozechner/pi-tui/dist/components/settings-list.js.map +1 -1
  117. package/node_modules/@mariozechner/pi-tui/dist/index.d.ts +8 -10
  118. package/node_modules/@mariozechner/pi-tui/dist/index.d.ts.map +1 -1
  119. package/node_modules/@mariozechner/pi-tui/dist/index.js +6 -9
  120. package/node_modules/@mariozechner/pi-tui/dist/index.js.map +1 -1
  121. package/node_modules/@mariozechner/pi-tui/dist/keybindings.d.ts +108 -238
  122. package/node_modules/@mariozechner/pi-tui/dist/keybindings.d.ts.map +1 -1
  123. package/node_modules/@mariozechner/pi-tui/dist/keybindings.js +108 -365
  124. package/node_modules/@mariozechner/pi-tui/dist/keybindings.js.map +1 -1
  125. package/node_modules/@mariozechner/pi-tui/dist/keys.d.ts +33 -48
  126. package/node_modules/@mariozechner/pi-tui/dist/keys.d.ts.map +1 -1
  127. package/node_modules/@mariozechner/pi-tui/dist/keys.js +239 -155
  128. package/node_modules/@mariozechner/pi-tui/dist/keys.js.map +1 -1
  129. package/node_modules/@mariozechner/pi-tui/dist/terminal-image.d.ts +14 -94
  130. package/node_modules/@mariozechner/pi-tui/dist/terminal-image.d.ts.map +1 -1
  131. package/node_modules/@mariozechner/pi-tui/dist/terminal-image.js +44 -186
  132. package/node_modules/@mariozechner/pi-tui/dist/terminal-image.js.map +1 -1
  133. package/node_modules/@mariozechner/pi-tui/dist/terminal.d.ts +13 -58
  134. package/node_modules/@mariozechner/pi-tui/dist/terminal.d.ts.map +1 -1
  135. package/node_modules/@mariozechner/pi-tui/dist/terminal.js +78 -111
  136. package/node_modules/@mariozechner/pi-tui/dist/terminal.js.map +1 -1
  137. package/node_modules/@mariozechner/pi-tui/dist/tui.d.ts +24 -110
  138. package/node_modules/@mariozechner/pi-tui/dist/tui.d.ts.map +1 -1
  139. package/node_modules/@mariozechner/pi-tui/dist/tui.js +188 -435
  140. package/node_modules/@mariozechner/pi-tui/dist/tui.js.map +1 -1
  141. package/node_modules/@mariozechner/pi-tui/dist/utils.d.ts +0 -18
  142. package/node_modules/@mariozechner/pi-tui/dist/utils.d.ts.map +1 -1
  143. package/node_modules/@mariozechner/pi-tui/dist/utils.js +251 -119
  144. package/node_modules/@mariozechner/pi-tui/dist/utils.js.map +1 -1
  145. package/node_modules/@mariozechner/pi-tui/package.json +6 -6
  146. package/node_modules/@mariozechner/pi-tui/src/__tests__/__snapshots__/render.test.ts.snap +3 -40
  147. package/node_modules/@mariozechner/pi-tui/src/__tests__/image-component.test.ts +71 -81
  148. package/node_modules/@mariozechner/pi-tui/src/__tests__/render.test.ts +0 -33
  149. package/node_modules/@mariozechner/pi-tui/src/__tests__/terminal-image.test.ts +93 -334
  150. package/node_modules/@mariozechner/pi-tui/src/__tests__/tui-render-scheduling.test.ts +1 -1
  151. package/node_modules/@mariozechner/pi-tui/src/__tests__/utils.test.ts +11 -196
  152. package/node_modules/@mariozechner/pi-tui/src/autocomplete.ts +228 -142
  153. package/node_modules/@mariozechner/pi-tui/src/components/cancellable-loader.ts +3 -3
  154. package/node_modules/@mariozechner/pi-tui/src/components/editor.ts +624 -390
  155. package/node_modules/@mariozechner/pi-tui/src/components/image.ts +17 -227
  156. package/node_modules/@mariozechner/pi-tui/src/components/input.ts +71 -63
  157. package/node_modules/@mariozechner/pi-tui/src/components/loader.ts +5 -137
  158. package/node_modules/@mariozechner/pi-tui/src/components/markdown.ts +143 -52
  159. package/node_modules/@mariozechner/pi-tui/src/components/select-list.ts +136 -70
  160. package/node_modules/@mariozechner/pi-tui/src/components/settings-list.ts +12 -51
  161. package/node_modules/@mariozechner/pi-tui/src/index.ts +17 -36
  162. package/node_modules/@mariozechner/pi-tui/src/keybindings.ts +148 -421
  163. package/node_modules/@mariozechner/pi-tui/src/keys.ts +253 -181
  164. package/node_modules/@mariozechner/pi-tui/src/terminal-image.ts +51 -252
  165. package/node_modules/@mariozechner/pi-tui/src/terminal.ts +78 -133
  166. package/node_modules/@mariozechner/pi-tui/src/tui.ts +202 -478
  167. package/node_modules/@mariozechner/pi-tui/src/utils.ts +289 -125
  168. package/node_modules/@mariozechner/pi-tui/tsconfig.build.json +1 -0
  169. package/package.json +13 -13
  170. package/packages/tallow-tui/node_modules/@types/mime-types/README.md +8 -2
  171. package/packages/tallow-tui/node_modules/@types/mime-types/index.d.ts +6 -0
  172. package/packages/tallow-tui/node_modules/@types/mime-types/package.json +9 -3
  173. package/packages/tallow-tui/node_modules/get-east-asian-width/lookup-data.js +18 -0
  174. package/packages/tallow-tui/node_modules/get-east-asian-width/lookup.js +116 -384
  175. package/packages/tallow-tui/node_modules/get-east-asian-width/package.json +5 -4
  176. package/packages/tallow-tui/node_modules/get-east-asian-width/utilities.js +24 -0
  177. package/packages/tallow-tui/node_modules/marked/README.md +5 -4
  178. package/packages/tallow-tui/node_modules/marked/bin/main.js +10 -8
  179. package/packages/tallow-tui/node_modules/marked/bin/marked.js +2 -1
  180. package/packages/tallow-tui/node_modules/marked/lib/marked.d.ts +156 -125
  181. package/packages/tallow-tui/node_modules/marked/lib/marked.esm.js +67 -2179
  182. package/packages/tallow-tui/node_modules/marked/lib/marked.esm.js.map +3 -3
  183. package/packages/tallow-tui/node_modules/marked/lib/marked.umd.js +67 -2201
  184. package/packages/tallow-tui/node_modules/marked/lib/marked.umd.js.map +3 -3
  185. package/packages/tallow-tui/node_modules/marked/man/marked.1 +4 -2
  186. package/packages/tallow-tui/node_modules/marked/man/marked.1.md +2 -1
  187. package/packages/tallow-tui/node_modules/marked/package.json +26 -34
  188. package/skills/tallow-expert/SKILL.md +3 -5
  189. package/node_modules/@mariozechner/pi-tui/dist/border-styles.d.ts +0 -32
  190. package/node_modules/@mariozechner/pi-tui/dist/border-styles.d.ts.map +0 -1
  191. package/node_modules/@mariozechner/pi-tui/dist/border-styles.js +0 -46
  192. package/node_modules/@mariozechner/pi-tui/dist/border-styles.js.map +0 -1
  193. package/node_modules/@mariozechner/pi-tui/dist/components/bordered-box.d.ts +0 -52
  194. package/node_modules/@mariozechner/pi-tui/dist/components/bordered-box.d.ts.map +0 -1
  195. package/node_modules/@mariozechner/pi-tui/dist/components/bordered-box.js +0 -89
  196. package/node_modules/@mariozechner/pi-tui/dist/components/bordered-box.js.map +0 -1
  197. package/node_modules/@mariozechner/pi-tui/dist/test-utils/capability-env.d.ts +0 -14
  198. package/node_modules/@mariozechner/pi-tui/dist/test-utils/capability-env.d.ts.map +0 -1
  199. package/node_modules/@mariozechner/pi-tui/dist/test-utils/capability-env.js +0 -55
  200. package/node_modules/@mariozechner/pi-tui/dist/test-utils/capability-env.js.map +0 -1
  201. package/node_modules/@mariozechner/pi-tui/src/__tests__/editor-change-listener.test.ts +0 -121
  202. package/node_modules/@mariozechner/pi-tui/src/__tests__/editor-ghost-text.test.ts +0 -112
  203. package/node_modules/@mariozechner/pi-tui/src/__tests__/mouse-events.test.ts +0 -134
  204. package/node_modules/@mariozechner/pi-tui/src/__tests__/settings-list.test.ts +0 -81
  205. package/node_modules/@mariozechner/pi-tui/src/__tests__/tui-diff-regression.test.ts +0 -555
  206. package/node_modules/@mariozechner/pi-tui/src/border-styles.ts +0 -60
  207. package/node_modules/@mariozechner/pi-tui/src/components/bordered-box.ts +0 -113
  208. package/node_modules/@mariozechner/pi-tui/src/test-utils/capability-env.ts +0 -56
  209. package/packages/tallow-tui/node_modules/marked/lib/marked.cjs +0 -2211
  210. package/packages/tallow-tui/node_modules/marked/lib/marked.cjs.map +0 -7
  211. package/packages/tallow-tui/node_modules/marked/lib/marked.d.cts +0 -728
  212. package/packages/tallow-tui/node_modules/marked/marked.min.js +0 -69
@@ -1,13 +1,91 @@
1
- import type { AutocompleteProvider, CombinedAutocompleteProvider } from "../autocomplete.js";
2
- import { getEditorKeybindings } from "../keybindings.js";
3
- import { matchesKey } from "../keys.js";
1
+ import type {
2
+ AutocompleteProvider,
3
+ AutocompleteSuggestions,
4
+ CombinedAutocompleteProvider,
5
+ } from "../autocomplete.js";
6
+ import { getKeybindings } from "../keybindings.js";
7
+ import { decodeKittyPrintable, matchesKey } from "../keys.js";
4
8
  import { KillRing } from "../kill-ring.js";
5
9
  import { type Component, CURSOR_MARKER, type Focusable, type TUI } from "../tui.js";
6
10
  import { UndoStack } from "../undo-stack.js";
7
- import { getSegmenter, isPunctuationChar, isWhitespaceChar, visibleWidth } from "../utils.js";
8
- import { SelectList, type SelectListTheme } from "./select-list.js";
11
+ import {
12
+ getSegmenter,
13
+ isPunctuationChar,
14
+ isWhitespaceChar,
15
+ truncateToWidth,
16
+ visibleWidth,
17
+ } from "../utils.js";
18
+ import { SelectList, type SelectListLayoutOptions, type SelectListTheme } from "./select-list.js";
19
+
20
+ const baseSegmenter = getSegmenter();
21
+
22
+ /** Regex matching paste markers like `[paste #1 +123 lines]` or `[paste #2 1234 chars]`. */
23
+ const PASTE_MARKER_REGEX = /\[paste #(\d+)( (\+\d+ lines|\d+ chars))?\]/g;
24
+
25
+ /** Non-global version for single-segment testing. */
26
+ const PASTE_MARKER_SINGLE = /^\[paste #(\d+)( (\+\d+ lines|\d+ chars))?\]$/;
27
+
28
+ /** Check if a segment is a paste marker (i.e. was merged by segmentWithMarkers). */
29
+ function isPasteMarker(segment: string): boolean {
30
+ return segment.length >= 10 && PASTE_MARKER_SINGLE.test(segment);
31
+ }
32
+
33
+ /**
34
+ * A segmenter that wraps Intl.Segmenter and merges graphemes that fall
35
+ * within paste markers into single atomic segments. This makes cursor
36
+ * movement, deletion, word-wrap, etc. treat paste markers as single units.
37
+ *
38
+ * Only markers whose numeric ID exists in `validIds` are merged.
39
+ */
40
+ function segmentWithMarkers(text: string, validIds: Set<number>): Iterable<Intl.SegmentData> {
41
+ // Fast path: no paste markers in the text or no valid IDs.
42
+ if (validIds.size === 0 || !text.includes("[paste #")) {
43
+ return baseSegmenter.segment(text);
44
+ }
45
+
46
+ // Find all marker spans with valid IDs.
47
+ const markers: Array<{ start: number; end: number }> = [];
48
+ for (const m of text.matchAll(PASTE_MARKER_REGEX)) {
49
+ const id = Number.parseInt(m[1]!, 10);
50
+ if (!validIds.has(id)) continue;
51
+ markers.push({ start: m.index, end: m.index + m[0].length });
52
+ }
53
+ if (markers.length === 0) {
54
+ return baseSegmenter.segment(text);
55
+ }
56
+
57
+ // Build merged segment list.
58
+ const baseSegments = baseSegmenter.segment(text);
59
+ const result: Intl.SegmentData[] = [];
60
+ let markerIdx = 0;
61
+
62
+ for (const seg of baseSegments) {
63
+ // Skip past markers that are entirely before this segment.
64
+ while (markerIdx < markers.length && markers[markerIdx]!.end <= seg.index) {
65
+ markerIdx++;
66
+ }
67
+
68
+ const marker = markerIdx < markers.length ? markers[markerIdx]! : null;
69
+
70
+ if (marker && seg.index >= marker.start && seg.index < marker.end) {
71
+ // This segment falls inside a marker.
72
+ // If this is the first segment of the marker, emit a merged segment.
73
+ if (seg.index === marker.start) {
74
+ const markerText = text.slice(marker.start, marker.end);
75
+ result.push({
76
+ segment: markerText,
77
+ index: marker.start,
78
+ input: text,
79
+ });
80
+ }
81
+ // Otherwise skip (already merged into the first segment).
82
+ } else {
83
+ result.push(seg);
84
+ }
85
+ }
9
86
 
10
- const segmenter = getSegmenter();
87
+ return result;
88
+ }
11
89
 
12
90
  /**
13
91
  * Represents a chunk of text for word-wrap layout.
@@ -26,9 +104,15 @@ export interface TextChunk {
26
104
  *
27
105
  * @param line - The text line to wrap
28
106
  * @param maxWidth - Maximum visible width per chunk
107
+ * @param preSegmented - Optional pre-segmented graphemes (e.g. with paste-marker awareness).
108
+ * When omitted the default Intl.Segmenter is used.
29
109
  * @returns Array of chunks with text and position information
30
110
  */
31
- export function wordWrapLine(line: string, maxWidth: number): TextChunk[] {
111
+ export function wordWrapLine(
112
+ line: string,
113
+ maxWidth: number,
114
+ preSegmented?: Intl.SegmentData[]
115
+ ): TextChunk[] {
32
116
  if (!line || maxWidth <= 0) {
33
117
  return [{ text: "", startIndex: 0, endIndex: 0 }];
34
118
  }
@@ -39,7 +123,7 @@ export function wordWrapLine(line: string, maxWidth: number): TextChunk[] {
39
123
  }
40
124
 
41
125
  const chunks: TextChunk[] = [];
42
- const segments = [...segmenter.segment(line)];
126
+ const segments = preSegmented ?? [...baseSegmenter.segment(line)];
43
127
 
44
128
  let currentWidth = 0;
45
129
  let chunkStart = 0;
@@ -54,12 +138,13 @@ export function wordWrapLine(line: string, maxWidth: number): TextChunk[] {
54
138
  const grapheme = seg.segment;
55
139
  const gWidth = visibleWidth(grapheme);
56
140
  const charIndex = seg.index;
57
- const isWs = isWhitespaceChar(grapheme);
141
+ const isWs = !isPasteMarker(grapheme) && isWhitespaceChar(grapheme);
58
142
 
59
143
  // Overflow check before advancing.
60
144
  if (currentWidth + gWidth > maxWidth) {
61
- if (wrapOppIndex >= 0) {
62
- // Backtrack to last wrap opportunity.
145
+ if (wrapOppIndex >= 0 && currentWidth - wrapOppWidth + gWidth <= maxWidth) {
146
+ // Backtrack to last wrap opportunity (the remaining content
147
+ // plus the current grapheme still fits within maxWidth).
63
148
  chunks.push({
64
149
  text: line.slice(chunkStart, wrapOppIndex),
65
150
  startIndex: chunkStart,
@@ -68,7 +153,11 @@ export function wordWrapLine(line: string, maxWidth: number): TextChunk[] {
68
153
  chunkStart = wrapOppIndex;
69
154
  currentWidth -= wrapOppWidth;
70
155
  } else if (chunkStart < charIndex) {
71
- // No wrap opportunity: force-break at current position.
156
+ // No viable wrap opportunity: force-break at current position.
157
+ // This also handles the case where backtracking to a word
158
+ // boundary wouldn't help because the remaining content plus
159
+ // the current grapheme (e.g. a wide character) still exceeds
160
+ // maxWidth.
72
161
  chunks.push({
73
162
  text: line.slice(chunkStart, charIndex),
74
163
  startIndex: chunkStart,
@@ -80,6 +169,28 @@ export function wordWrapLine(line: string, maxWidth: number): TextChunk[] {
80
169
  wrapOppIndex = -1;
81
170
  }
82
171
 
172
+ if (gWidth > maxWidth) {
173
+ // Single atomic segment wider than maxWidth (e.g. paste marker
174
+ // in a narrow terminal). Re-wrap it at grapheme granularity.
175
+
176
+ // The segment remains logically atomic for cursor
177
+ // movement / editing — the split is purely visual for word-wrap layout.
178
+ const subChunks = wordWrapLine(grapheme, maxWidth);
179
+ for (let j = 0; j < subChunks.length - 1; j++) {
180
+ const sc = subChunks[j]!;
181
+ chunks.push({
182
+ text: sc.text,
183
+ startIndex: charIndex + sc.startIndex,
184
+ endIndex: charIndex + sc.endIndex,
185
+ });
186
+ }
187
+ const last = subChunks[subChunks.length - 1]!;
188
+ chunkStart = charIndex + last.startIndex;
189
+ currentWidth = visibleWidth(last.text);
190
+ wrapOppIndex = -1;
191
+ continue;
192
+ }
193
+
83
194
  // Advance.
84
195
  currentWidth += gWidth;
85
196
 
@@ -87,7 +198,7 @@ export function wordWrapLine(line: string, maxWidth: number): TextChunk[] {
87
198
  // Multiple spaces join (no break between them); the break point is
88
199
  // after the last space before the next word.
89
200
  const next = segments[i + 1];
90
- if (isWs && next && !isWhitespaceChar(next.segment)) {
201
+ if (isWs && next && (isPasteMarker(next.segment) || !isWhitespaceChar(next.segment))) {
91
202
  wrapOppIndex = next.index;
92
203
  wrapOppWidth = currentWidth;
93
204
  }
@@ -100,43 +211,6 @@ export function wordWrapLine(line: string, maxWidth: number): TextChunk[] {
100
211
  }
101
212
 
102
213
  // Kitty CSI-u sequences for printable keys, including optional shifted/base codepoints.
103
- const KITTY_CSI_U_REGEX = /^\x1b\[(\d+)(?::(\d*))?(?::(\d+))?(?:;(\d+))?(?::(\d+))?u$/;
104
- const KITTY_MOD_SHIFT = 1;
105
- const KITTY_MOD_ALT = 2;
106
- const KITTY_MOD_CTRL = 4;
107
-
108
- // Decode a printable CSI-u sequence, preferring the shifted key when present.
109
- function decodeKittyPrintable(data: string): string | undefined {
110
- const match = data.match(KITTY_CSI_U_REGEX);
111
- if (!match) return undefined;
112
-
113
- // CSI-u groups: <codepoint>[:<shifted>[:<base>]];<mod>u
114
- const codepoint = Number.parseInt(match[1] ?? "", 10);
115
- if (!Number.isFinite(codepoint)) return undefined;
116
-
117
- const shiftedKey = match[2] && match[2].length > 0 ? Number.parseInt(match[2], 10) : undefined;
118
- const modValue = match[4] ? Number.parseInt(match[4], 10) : 1;
119
- // Modifiers are 1-indexed in CSI-u; normalize to our bitmask.
120
- const modifier = Number.isFinite(modValue) ? modValue - 1 : 0;
121
-
122
- // Ignore CSI-u sequences used for Alt/Ctrl shortcuts.
123
- if (modifier & (KITTY_MOD_ALT | KITTY_MOD_CTRL)) return undefined;
124
-
125
- // Prefer the shifted keycode when Shift is held.
126
- let effectiveCodepoint = codepoint;
127
- if (modifier & KITTY_MOD_SHIFT && typeof shiftedKey === "number") {
128
- effectiveCodepoint = shiftedKey;
129
- }
130
- // Drop control characters or invalid codepoints.
131
- if (!Number.isFinite(effectiveCodepoint) || effectiveCodepoint < 32) return undefined;
132
-
133
- try {
134
- return String.fromCodePoint(effectiveCodepoint);
135
- } catch {
136
- return undefined;
137
- }
138
- }
139
-
140
214
  interface EditorState {
141
215
  lines: string[];
142
216
  cursorLine: number;
@@ -159,6 +233,13 @@ export interface EditorOptions {
159
233
  autocompleteMaxVisible?: number;
160
234
  }
161
235
 
236
+ const SLASH_COMMAND_SELECT_LIST_LAYOUT: SelectListLayoutOptions = {
237
+ minPrimaryColumnWidth: 12,
238
+ maxPrimaryColumnWidth: 32,
239
+ };
240
+
241
+ const ATTACHMENT_AUTOCOMPLETE_DEBOUNCE_MS = 20;
242
+
162
243
  export class Editor implements Component, Focusable {
163
244
  private state: EditorState = {
164
245
  lines: [""],
@@ -188,6 +269,11 @@ export class Editor implements Component, Focusable {
188
269
  private autocompleteState: "regular" | "force" | null = null;
189
270
  private autocompletePrefix: string = "";
190
271
  private autocompleteMaxVisible: number = 5;
272
+ private autocompleteAbort?: AbortController;
273
+ private autocompleteDebounceTimer?: ReturnType<typeof setTimeout>;
274
+ private autocompleteRequestTask: Promise<void> = Promise.resolve();
275
+ private autocompleteStartToken: number = 0;
276
+ private autocompleteRequestId: number = 0;
191
277
 
192
278
  // Paste tracking for large pastes
193
279
  private pastes: Map<number, string> = new Map();
@@ -211,15 +297,16 @@ export class Editor implements Component, Focusable {
211
297
  // Preferred visual column for vertical cursor movement (sticky column)
212
298
  private preferredVisualCol: number | null = null;
213
299
 
300
+ // When the cursor is snapped to the start of an atomic segment, e.g. a
301
+ // paste marker, cursorCol no longer reflects where the cursor would have
302
+ // landed. This field stores the pre-snap cursorCol so that the next
303
+ // vertical move can resolve it to a visual column on whatever VL it belongs
304
+ // to.
305
+ private snappedFromCursorCol: number | null = null;
306
+
214
307
  // Undo support
215
308
  private undoStack = new UndoStack<EditorState>();
216
309
 
217
- // Ghost text (inline suggestion shown as dim text after cursor)
218
- private ghostTextValue: string | null = null;
219
-
220
- /** Additional change listeners that won't be overwritten by framework wiring. */
221
- private changeListeners: ((text: string) => void)[] = [];
222
-
223
310
  public onSubmit?: (text: string) => void;
224
311
  public onChange?: (text: string) => void;
225
312
  public disableSubmit: boolean = false;
@@ -236,6 +323,16 @@ export class Editor implements Component, Focusable {
236
323
  : 5;
237
324
  }
238
325
 
326
+ /** Set of currently valid paste IDs, for marker-aware segmentation. */
327
+ private validPasteIds(): Set<number> {
328
+ return new Set(this.pastes.keys());
329
+ }
330
+
331
+ /** Segment text with paste-marker awareness, only merging markers with valid IDs. */
332
+ private segment(text: string): Iterable<Intl.SegmentData> {
333
+ return segmentWithMarkers(text, this.validPasteIds());
334
+ }
335
+
239
336
  getPaddingX(): number {
240
337
  return this.paddingX;
241
338
  }
@@ -263,6 +360,7 @@ export class Editor implements Component, Focusable {
263
360
  }
264
361
 
265
362
  setAutocompleteProvider(provider: AutocompleteProvider): void {
363
+ this.cancelAutocomplete();
266
364
  this.autocompleteProvider = provider;
267
365
  }
268
366
 
@@ -322,14 +420,16 @@ export class Editor implements Component, Focusable {
322
420
 
323
421
  /** Internal setText that doesn't reset history state - used by navigateHistory */
324
422
  private setTextInternal(text: string): void {
325
- const lines = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n");
423
+ const lines = text.split("\n");
326
424
  this.state.lines = lines.length === 0 ? [""] : lines;
327
425
  this.state.cursorLine = this.state.lines.length - 1;
328
426
  this.setCursorCol(this.state.lines[this.state.cursorLine]?.length || 0);
329
427
  // Reset scroll - render() will adjust to show cursor
330
428
  this.scrollOffset = 0;
331
429
 
332
- this.notifyChange();
430
+ if (this.onChange) {
431
+ this.onChange(this.getText());
432
+ }
333
433
  }
334
434
 
335
435
  invalidate(): void {
@@ -383,7 +483,11 @@ export class Editor implements Component, Focusable {
383
483
  if (this.scrollOffset > 0) {
384
484
  const indicator = `─── ↑ ${this.scrollOffset} more `;
385
485
  const remaining = width - visibleWidth(indicator);
386
- result.push(this.borderColor(indicator + "─".repeat(Math.max(0, remaining))));
486
+ if (remaining >= 0) {
487
+ result.push(this.borderColor(indicator + "─".repeat(remaining)));
488
+ } else {
489
+ result.push(this.borderColor(truncateToWidth(indicator, width)));
490
+ }
387
491
  } else {
388
492
  result.push(horizontal.repeat(width));
389
493
  }
@@ -408,7 +512,7 @@ export class Editor implements Component, Focusable {
408
512
  if (after.length > 0) {
409
513
  // Cursor is on a character (grapheme) - replace it with highlighted version
410
514
  // Get the first grapheme from 'after'
411
- const afterGraphemes = [...segmenter.segment(after)];
515
+ const afterGraphemes = [...this.segment(after)];
412
516
  const firstGrapheme = afterGraphemes[0]?.segment || "";
413
517
  const restAfter = after.slice(firstGrapheme.length);
414
518
  const cursor = `\x1b[7m${firstGrapheme}\x1b[0m`;
@@ -426,25 +530,6 @@ export class Editor implements Component, Focusable {
426
530
  }
427
531
  }
428
532
 
429
- // Ghost text: show dim suggestion after cursor on the cursor line (end of input only)
430
- if (
431
- layoutLine.hasCursor &&
432
- this.ghostTextValue &&
433
- layoutLine.cursorPos !== undefined &&
434
- layoutLine.cursorPos >= layoutLine.text.length
435
- ) {
436
- // Truncate ghost text to fit remaining content width
437
- const available = contentWidth - lineVisibleWidth;
438
- if (available > 0) {
439
- const truncated =
440
- this.ghostTextValue.length > available
441
- ? this.ghostTextValue.slice(0, available)
442
- : this.ghostTextValue;
443
- displayText += `\x1b[38;5;242m${truncated}\x1b[0m`;
444
- lineVisibleWidth += truncated.length;
445
- }
446
- }
447
-
448
533
  // Calculate padding based on actual visible width
449
534
  const padding = " ".repeat(Math.max(0, contentWidth - lineVisibleWidth));
450
535
  const lineRightPadding = cursorInPadding ? rightPadding.slice(1) : rightPadding;
@@ -477,12 +562,15 @@ export class Editor implements Component, Focusable {
477
562
  }
478
563
 
479
564
  handleInput(data: string): void {
480
- const kb = getEditorKeybindings();
565
+ const kb = getKeybindings();
481
566
 
482
567
  // Handle character jump mode (awaiting next character to jump to)
483
568
  if (this.jumpMode !== null) {
484
569
  // Cancel if the hotkey is pressed again
485
- if (kb.matches(data, "jumpForward") || kb.matches(data, "jumpBackward")) {
570
+ if (
571
+ kb.matches(data, "tui.editor.jumpForward") ||
572
+ kb.matches(data, "tui.editor.jumpBackward")
573
+ ) {
486
574
  this.jumpMode = null;
487
575
  return;
488
576
  }
@@ -526,29 +614,29 @@ export class Editor implements Component, Focusable {
526
614
  }
527
615
 
528
616
  // Ctrl+C - let parent handle (exit/clear)
529
- if (kb.matches(data, "copy")) {
617
+ if (kb.matches(data, "tui.input.copy")) {
530
618
  return;
531
619
  }
532
620
 
533
621
  // Undo
534
- if (kb.matches(data, "undo")) {
622
+ if (kb.matches(data, "tui.editor.undo")) {
535
623
  this.undo();
536
624
  return;
537
625
  }
538
626
 
539
627
  // Handle autocomplete mode
540
628
  if (this.autocompleteState && this.autocompleteList) {
541
- if (kb.matches(data, "selectCancel")) {
629
+ if (kb.matches(data, "tui.select.cancel")) {
542
630
  this.cancelAutocomplete();
543
631
  return;
544
632
  }
545
633
 
546
- if (kb.matches(data, "selectUp") || kb.matches(data, "selectDown")) {
634
+ if (kb.matches(data, "tui.select.up") || kb.matches(data, "tui.select.down")) {
547
635
  this.autocompleteList.handleInput(data);
548
636
  return;
549
637
  }
550
638
 
551
- if (kb.matches(data, "tab")) {
639
+ if (kb.matches(data, "tui.input.tab")) {
552
640
  const selected = this.autocompleteList.getSelectedItem();
553
641
  if (selected && this.autocompleteProvider) {
554
642
  this.pushUndoSnapshot();
@@ -564,12 +652,12 @@ export class Editor implements Component, Focusable {
564
652
  this.state.cursorLine = result.cursorLine;
565
653
  this.setCursorCol(result.cursorCol);
566
654
  this.cancelAutocomplete();
567
- this.notifyChange();
655
+ if (this.onChange) this.onChange(this.getText());
568
656
  }
569
657
  return;
570
658
  }
571
659
 
572
- if (kb.matches(data, "selectConfirm")) {
660
+ if (kb.matches(data, "tui.select.confirm")) {
573
661
  const selected = this.autocompleteList.getSelectedItem();
574
662
  if (selected && this.autocompleteProvider) {
575
663
  this.pushUndoSnapshot();
@@ -590,87 +678,76 @@ export class Editor implements Component, Focusable {
590
678
  // Fall through to submit
591
679
  } else {
592
680
  this.cancelAutocomplete();
593
- this.notifyChange();
681
+ if (this.onChange) this.onChange(this.getText());
594
682
  return;
595
683
  }
596
684
  }
597
685
  }
598
686
  }
599
687
 
600
- // Escape - dismiss ghost text when no autocomplete is active
601
- if (kb.matches(data, "selectCancel") && !this.autocompleteState && this.ghostTextValue) {
602
- this.ghostTextValue = null;
603
- this.tui.requestRender();
604
- return;
605
- }
606
-
607
- // Tab - accept ghost text or trigger completion
608
- if (kb.matches(data, "tab") && !this.autocompleteState) {
609
- if (this.ghostTextValue) {
610
- this.acceptGhostText();
611
- return;
612
- }
688
+ // Tab - trigger completion
689
+ if (kb.matches(data, "tui.input.tab") && !this.autocompleteState) {
613
690
  this.handleTabCompletion();
614
691
  return;
615
692
  }
616
693
 
617
694
  // Deletion actions
618
- if (kb.matches(data, "deleteToLineEnd")) {
695
+ if (kb.matches(data, "tui.editor.deleteToLineEnd")) {
619
696
  this.deleteToEndOfLine();
620
697
  return;
621
698
  }
622
- if (kb.matches(data, "deleteToLineStart")) {
699
+ if (kb.matches(data, "tui.editor.deleteToLineStart")) {
623
700
  this.deleteToStartOfLine();
624
701
  return;
625
702
  }
626
- if (kb.matches(data, "deleteWordBackward")) {
703
+ if (kb.matches(data, "tui.editor.deleteWordBackward")) {
627
704
  this.deleteWordBackwards();
628
705
  return;
629
706
  }
630
- if (kb.matches(data, "deleteWordForward")) {
707
+ if (kb.matches(data, "tui.editor.deleteWordForward")) {
631
708
  this.deleteWordForward();
632
709
  return;
633
710
  }
634
- if (kb.matches(data, "deleteCharBackward") || matchesKey(data, "shift+backspace")) {
711
+ if (kb.matches(data, "tui.editor.deleteCharBackward") || matchesKey(data, "shift+backspace")) {
635
712
  this.handleBackspace();
636
713
  return;
637
714
  }
638
- if (kb.matches(data, "deleteCharForward") || matchesKey(data, "shift+delete")) {
715
+ if (kb.matches(data, "tui.editor.deleteCharForward") || matchesKey(data, "shift+delete")) {
639
716
  this.handleForwardDelete();
640
717
  return;
641
718
  }
642
719
 
643
720
  // Kill ring actions
644
- if (kb.matches(data, "yank")) {
721
+ if (kb.matches(data, "tui.editor.yank")) {
645
722
  this.yank();
646
723
  return;
647
724
  }
648
- if (kb.matches(data, "yankPop")) {
725
+ if (kb.matches(data, "tui.editor.yankPop")) {
649
726
  this.yankPop();
650
727
  return;
651
728
  }
652
729
 
653
730
  // Cursor movement actions
654
- if (kb.matches(data, "cursorLineStart")) {
731
+ if (kb.matches(data, "tui.editor.cursorLineStart")) {
655
732
  this.moveToLineStart();
656
733
  return;
657
734
  }
658
- if (kb.matches(data, "cursorLineEnd")) {
735
+ if (kb.matches(data, "tui.editor.cursorLineEnd")) {
659
736
  this.moveToLineEnd();
660
737
  return;
661
738
  }
662
- if (kb.matches(data, "cursorWordLeft")) {
739
+ if (kb.matches(data, "tui.editor.cursorWordLeft")) {
663
740
  this.moveWordBackwards();
664
741
  return;
665
742
  }
666
- if (kb.matches(data, "cursorWordRight")) {
743
+ if (kb.matches(data, "tui.editor.cursorWordRight")) {
667
744
  this.moveWordForwards();
668
745
  return;
669
746
  }
670
747
 
671
748
  // New line
672
749
  if (
673
- kb.matches(data, "newLine") ||
750
+ kb.matches(data, "tui.input.newLine") ||
674
751
  (data.charCodeAt(0) === 10 && data.length > 1) ||
675
752
  data === "\x1b\r" ||
676
753
  data === "\x1b[13;2~" ||
@@ -687,16 +764,9 @@ export class Editor implements Component, Focusable {
687
764
  }
688
765
 
689
766
  // Submit (Enter)
690
- if (kb.matches(data, "submit")) {
767
+ if (kb.matches(data, "tui.input.submit")) {
691
768
  if (this.disableSubmit) return;
692
769
 
693
- // Accept ghost text on Enter when input is empty — "just hit Enter" experience
694
- if (this.isEditorEmpty() && this.ghostTextValue) {
695
- this.acceptGhostText();
696
- this.submitValue();
697
- return;
698
- }
699
-
700
770
  // Workaround for terminals without Shift+Enter support:
701
771
  // If char before cursor is \, delete it and insert newline instead of submitting.
702
772
  const currentLine = this.state.lines[this.state.cursorLine] || "";
@@ -711,7 +781,7 @@ export class Editor implements Component, Focusable {
711
781
  }
712
782
 
713
783
  // Arrow key navigation (with history support)
714
- if (kb.matches(data, "cursorUp")) {
784
+ if (kb.matches(data, "tui.editor.cursorUp")) {
715
785
  if (this.isEditorEmpty()) {
716
786
  this.navigateHistory(-1);
717
787
  } else if (this.historyIndex > -1 && this.isOnFirstVisualLine()) {
@@ -724,7 +794,7 @@ export class Editor implements Component, Focusable {
724
794
  }
725
795
  return;
726
796
  }
727
- if (kb.matches(data, "cursorDown")) {
797
+ if (kb.matches(data, "tui.editor.cursorDown")) {
728
798
  if (this.historyIndex > -1 && this.isOnLastVisualLine()) {
729
799
  this.navigateHistory(1);
730
800
  } else if (this.isOnLastVisualLine()) {
@@ -735,31 +805,31 @@ export class Editor implements Component, Focusable {
735
805
  }
736
806
  return;
737
807
  }
738
- if (kb.matches(data, "cursorRight")) {
808
+ if (kb.matches(data, "tui.editor.cursorRight")) {
739
809
  this.moveCursor(0, 1);
740
810
  return;
741
811
  }
742
- if (kb.matches(data, "cursorLeft")) {
812
+ if (kb.matches(data, "tui.editor.cursorLeft")) {
743
813
  this.moveCursor(0, -1);
744
814
  return;
745
815
  }
746
816
 
747
817
  // Page up/down - scroll by page and move cursor
748
- if (kb.matches(data, "pageUp")) {
818
+ if (kb.matches(data, "tui.editor.pageUp")) {
749
819
  this.pageScroll(-1);
750
820
  return;
751
821
  }
752
- if (kb.matches(data, "pageDown")) {
822
+ if (kb.matches(data, "tui.editor.pageDown")) {
753
823
  this.pageScroll(1);
754
824
  return;
755
825
  }
756
826
 
757
827
  // Character jump mode triggers
758
- if (kb.matches(data, "jumpForward")) {
828
+ if (kb.matches(data, "tui.editor.jumpForward")) {
759
829
  this.jumpMode = "forward";
760
830
  return;
761
831
  }
762
- if (kb.matches(data, "jumpBackward")) {
832
+ if (kb.matches(data, "tui.editor.jumpBackward")) {
763
833
  this.jumpMode = "backward";
764
834
  return;
765
835
  }
@@ -820,7 +890,7 @@ export class Editor implements Component, Focusable {
820
890
  }
821
891
  } else {
822
892
  // Line needs wrapping - use word-aware wrapping
823
- const chunks = wordWrapLine(line, contentWidth);
893
+ const chunks = wordWrapLine(line, contentWidth, [...this.segment(line)]);
824
894
 
825
895
  for (let chunkIndex = 0; chunkIndex < chunks.length; chunkIndex++) {
826
896
  const chunk = chunks[chunkIndex];
@@ -877,17 +947,21 @@ export class Editor implements Component, Focusable {
877
947
  return this.state.lines.join("\n");
878
948
  }
879
949
 
950
+ private expandPasteMarkers(text: string): string {
951
+ let result = text;
952
+ for (const [pasteId, pasteContent] of this.pastes) {
953
+ const markerRegex = new RegExp(`\\[paste #${pasteId}( (\\+\\d+ lines|\\d+ chars))?\\]`, "g");
954
+ result = result.replace(markerRegex, () => pasteContent);
955
+ }
956
+ return result;
957
+ }
958
+
880
959
  /**
881
960
  * Get text with paste markers expanded to their actual content.
882
961
  * Use this when you need the full content (e.g., for external editor).
883
962
  */
884
963
  getExpandedText(): string {
885
- let result = this.state.lines.join("\n");
886
- for (const [pasteId, pasteContent] of this.pastes) {
887
- const markerRegex = new RegExp(`\\[paste #${pasteId}( (\\+\\d+ lines|\\d+ chars))?\\]`, "g");
888
- result = result.replace(markerRegex, pasteContent);
889
- }
890
- return result;
964
+ return this.expandPasteMarkers(this.state.lines.join("\n"));
891
965
  }
892
966
 
893
967
  getLines(): string[] {
@@ -899,73 +973,15 @@ export class Editor implements Component, Focusable {
899
973
  }
900
974
 
901
975
  setText(text: string): void {
976
+ this.cancelAutocomplete();
902
977
  this.lastAction = null;
903
978
  this.historyIndex = -1; // Exit history browsing mode
979
+ const normalized = this.normalizeText(text);
904
980
  // Push undo snapshot if content differs (makes programmatic changes undoable)
905
- if (this.getText() !== text) {
981
+ if (this.getText() !== normalized) {
906
982
  this.pushUndoSnapshot();
907
983
  }
908
- this.setTextInternal(text);
909
- }
910
-
911
- /**
912
- * Set ghost text (inline suggestion shown as dim text after the cursor).
913
- * Pass null to clear. Ghost text is purely visual — not part of the buffer.
914
- *
915
- * @param text - Suggestion to display, or null to clear
916
- */
917
- setGhostText(text: string | null): void {
918
- if (this.ghostTextValue !== text) {
919
- this.ghostTextValue = text;
920
- this.tui.requestRender();
921
- }
922
- }
923
-
924
- /**
925
- * Get the current ghost text, or null if none.
926
- *
927
- * @returns Current ghost text string, or null
928
- */
929
- getGhostText(): string | null {
930
- return this.ghostTextValue;
931
- }
932
-
933
- /**
934
- * Register a change listener that fires alongside onChange.
935
- * Unlike onChange, listeners aren't overwritten by framework wiring.
936
- *
937
- * @param fn - Callback receiving the new text content
938
- */
939
- addChangeListener(fn: (text: string) => void): void {
940
- this.changeListeners.push(fn);
941
- }
942
-
943
- /**
944
- * Notify onChange and all registered change listeners.
945
- * Centralises all text-change notifications.
946
- */
947
- private notifyChange(): void {
948
- const text = this.getText();
949
- this.onChange?.(text);
950
- for (const fn of this.changeListeners) {
951
- fn(text);
952
- }
953
- }
954
-
955
- /**
956
- * Accept ghost text into the editor buffer at the cursor position.
957
- * Clears ghost text and triggers onChange.
958
- *
959
- * @returns true if ghost text was accepted, false if none was showing
960
- */
961
- private acceptGhostText(): boolean {
962
- if (!this.ghostTextValue) return false;
963
- const text = this.ghostTextValue;
964
- this.ghostTextValue = null;
965
- this.pushUndoSnapshot();
966
- this.insertTextAtCursorInternal(text);
967
- this.notifyChange();
968
- return true;
984
+ this.setTextInternal(normalized);
969
985
  }
970
986
 
971
987
  /**
@@ -975,12 +991,22 @@ export class Editor implements Component, Focusable {
975
991
  */
976
992
  insertTextAtCursor(text: string): void {
977
993
  if (!text) return;
994
+ this.cancelAutocomplete();
978
995
  this.pushUndoSnapshot();
979
996
  this.lastAction = null;
980
997
  this.historyIndex = -1;
981
998
  this.insertTextAtCursorInternal(text);
982
999
  }
983
1000
 
1001
+ /**
1002
+ * Normalize text for editor storage:
1003
+ * - Normalize line endings (\r\n and \r -> \n)
1004
+ * - Expand tabs to 4 spaces
1005
+ */
1006
+ private normalizeText(text: string): string {
1007
+ return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n").replace(/\t/g, " ");
1008
+ }
1009
+
984
1010
  /**
985
1011
  * Internal text insertion at cursor. Handles single and multi-line text.
986
1012
  * Does not push undo snapshots or trigger autocomplete - caller is responsible.
@@ -989,8 +1015,8 @@ export class Editor implements Component, Focusable {
989
1015
  private insertTextAtCursorInternal(text: string): void {
990
1016
  if (!text) return;
991
1017
 
992
- // Normalize line endings
993
- const normalized = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
1018
+ // Normalize line endings and tabs
1019
+ const normalized = this.normalizeText(text);
994
1020
  const insertedLines = normalized.split("\n");
995
1021
 
996
1022
  const currentLine = this.state.lines[this.state.cursorLine] || "";
@@ -1024,14 +1050,14 @@ export class Editor implements Component, Focusable {
1024
1050
  this.setCursorCol((insertedLines[insertedLines.length - 1] || "").length);
1025
1051
  }
1026
1052
 
1027
- this.notifyChange();
1053
+ if (this.onChange) {
1054
+ this.onChange(this.getText());
1055
+ }
1028
1056
  }
1029
1057
 
1030
1058
  // All the editor methods from before...
1031
1059
  private insertCharacter(char: string, skipUndoCoalescing?: boolean): void {
1032
1060
  this.historyIndex = -1; // Exit history browsing mode
1033
- // Dismiss ghost text on any character input
1034
- this.ghostTextValue = null;
1035
1061
 
1036
1062
  // Undo coalescing (fish-style):
1037
1063
  // - Consecutive word chars coalesce into one undo unit
@@ -1053,7 +1079,9 @@ export class Editor implements Component, Focusable {
1053
1079
  this.state.lines[this.state.cursorLine] = before + char + after;
1054
1080
  this.setCursorCol(this.state.cursorCol + char.length);
1055
1081
 
1056
- this.notifyChange();
1082
+ if (this.onChange) {
1083
+ this.onChange(this.getText());
1084
+ }
1057
1085
 
1058
1086
  // Check if we should trigger or update autocomplete
1059
1087
  if (!this.autocompleteState) {
@@ -1090,19 +1118,17 @@ export class Editor implements Component, Focusable {
1090
1118
  }
1091
1119
 
1092
1120
  private handlePaste(pastedText: string): void {
1121
+ this.cancelAutocomplete();
1093
1122
  this.historyIndex = -1; // Exit history browsing mode
1094
1123
  this.lastAction = null;
1095
1124
 
1096
1125
  this.pushUndoSnapshot();
1097
1126
 
1098
- // Clean the pasted text
1099
- const cleanText = pastedText.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
1100
-
1101
- // Convert tabs to spaces (4 spaces per tab)
1102
- const tabExpandedText = cleanText.replace(/\t/g, " ");
1127
+ // Clean the pasted text: normalize line endings, expand tabs
1128
+ const cleanText = this.normalizeText(pastedText);
1103
1129
 
1104
1130
  // Filter out non-printable characters except newlines
1105
- let filteredText = tabExpandedText
1131
+ let filteredText = cleanText
1106
1132
  .split("")
1107
1133
  .filter((char) => char === "\n" || char.charCodeAt(0) >= 32)
1108
1134
  .join("");
@@ -1139,10 +1165,8 @@ export class Editor implements Component, Focusable {
1139
1165
  }
1140
1166
 
1141
1167
  if (pastedLines.length === 1) {
1142
- // Single line - insert character by character to trigger autocomplete
1143
- for (const char of filteredText) {
1144
- this.insertCharacter(char, true);
1145
- }
1168
+ // Single line - insert atomically (do not trigger autocomplete during paste)
1169
+ this.insertTextAtCursorInternal(filteredText);
1146
1170
  return;
1147
1171
  }
1148
1172
 
@@ -1151,6 +1175,7 @@ export class Editor implements Component, Focusable {
1151
1175
  }
1152
1176
 
1153
1177
  private addNewLine(): void {
1178
+ this.cancelAutocomplete();
1154
1179
  this.historyIndex = -1; // Exit history browsing mode
1155
1180
  this.lastAction = null;
1156
1181
 
@@ -1169,16 +1194,18 @@ export class Editor implements Component, Focusable {
1169
1194
  this.state.cursorLine++;
1170
1195
  this.setCursorCol(0);
1171
1196
 
1172
- this.notifyChange();
1197
+ if (this.onChange) {
1198
+ this.onChange(this.getText());
1199
+ }
1173
1200
  }
1174
1201
 
1175
1202
  private shouldSubmitOnBackslashEnter(
1176
1203
  data: string,
1177
- kb: ReturnType<typeof getEditorKeybindings>
1204
+ kb: ReturnType<typeof getKeybindings>
1178
1205
  ): boolean {
1179
1206
  if (this.disableSubmit) return false;
1180
1207
  if (!matchesKey(data, "enter")) return false;
1181
- const submitKeys = kb.getKeys("submit");
1208
+ const submitKeys = kb.getKeys("tui.input.submit");
1182
1209
  const hasShiftEnter = submitKeys.includes("shift+enter") || submitKeys.includes("shift+return");
1183
1210
  if (!hasShiftEnter) return false;
1184
1211
 
@@ -1187,11 +1214,8 @@ export class Editor implements Component, Focusable {
1187
1214
  }
1188
1215
 
1189
1216
  private submitValue(): void {
1190
- let result = this.state.lines.join("\n").trim();
1191
- for (const [pasteId, pasteContent] of this.pastes) {
1192
- const markerRegex = new RegExp(`\\[paste #${pasteId}( (\\+\\d+ lines|\\d+ chars))?\\]`, "g");
1193
- result = result.replace(markerRegex, pasteContent);
1194
- }
1217
+ this.cancelAutocomplete();
1218
+ const result = this.expandPasteMarkers(this.state.lines.join("\n")).trim();
1195
1219
 
1196
1220
  this.state = { lines: [""], cursorLine: 0, cursorCol: 0 };
1197
1221
  this.pastes.clear();
@@ -1201,14 +1225,13 @@ export class Editor implements Component, Focusable {
1201
1225
  this.undoStack.clear();
1202
1226
  this.lastAction = null;
1203
1227
 
1204
- this.notifyChange();
1228
+ if (this.onChange) this.onChange("");
1205
1229
  if (this.onSubmit) this.onSubmit(result);
1206
1230
  }
1207
1231
 
1208
1232
  private handleBackspace(): void {
1209
1233
  this.historyIndex = -1; // Exit history browsing mode
1210
1234
  this.lastAction = null;
1211
- this.ghostTextValue = null;
1212
1235
 
1213
1236
  if (this.state.cursorCol > 0) {
1214
1237
  this.pushUndoSnapshot();
@@ -1218,7 +1241,7 @@ export class Editor implements Component, Focusable {
1218
1241
  const beforeCursor = line.slice(0, this.state.cursorCol);
1219
1242
 
1220
1243
  // Find the last grapheme in the text before cursor
1221
- const graphemes = [...segmenter.segment(beforeCursor)];
1244
+ const graphemes = [...this.segment(beforeCursor)];
1222
1245
  const lastGrapheme = graphemes[graphemes.length - 1];
1223
1246
  const graphemeLength = lastGrapheme ? lastGrapheme.segment.length : 1;
1224
1247
 
@@ -1241,7 +1264,9 @@ export class Editor implements Component, Focusable {
1241
1264
  this.setCursorCol(previousLine.length);
1242
1265
  }
1243
1266
 
1244
- this.notifyChange();
1267
+ if (this.onChange) {
1268
+ this.onChange(this.getText());
1269
+ }
1245
1270
 
1246
1271
  // Update or re-trigger autocomplete after backspace
1247
1272
  if (this.autocompleteState) {
@@ -1268,6 +1293,7 @@ export class Editor implements Component, Focusable {
1268
1293
  private setCursorCol(col: number): void {
1269
1294
  this.state.cursorCol = col;
1270
1295
  this.preferredVisualCol = null;
1296
+ this.snappedFromCursorCol = null;
1271
1297
  }
1272
1298
 
1273
1299
  /**
@@ -1281,37 +1307,91 @@ export class Editor implements Component, Focusable {
1281
1307
  ): void {
1282
1308
  const currentVL = visualLines[currentVisualLine];
1283
1309
  const targetVL = visualLines[targetVisualLine];
1284
-
1285
- if (currentVL && targetVL) {
1286
- const currentVisualCol = this.state.cursorCol - currentVL.startCol;
1287
-
1288
- // For non-last segments, clamp to length-1 to stay within the segment
1289
- const isLastSourceSegment =
1290
- currentVisualLine === visualLines.length - 1 ||
1291
- visualLines[currentVisualLine + 1]?.logicalLine !== currentVL.logicalLine;
1292
- const sourceMaxVisualCol = isLastSourceSegment
1293
- ? currentVL.length
1294
- : Math.max(0, currentVL.length - 1);
1295
-
1296
- const isLastTargetSegment =
1297
- targetVisualLine === visualLines.length - 1 ||
1298
- visualLines[targetVisualLine + 1]?.logicalLine !== targetVL.logicalLine;
1299
- const targetMaxVisualCol = isLastTargetSegment
1300
- ? targetVL.length
1301
- : Math.max(0, targetVL.length - 1);
1302
-
1303
- const moveToVisualCol = this.computeVerticalMoveColumn(
1304
- currentVisualCol,
1305
- sourceMaxVisualCol,
1306
- targetMaxVisualCol
1310
+ if (!(currentVL && targetVL)) return;
1311
+
1312
+ // When the cursor was snapped to a segment start, resolve the pre-snap
1313
+ // position against the VL it belongs to. This gives the correct visual
1314
+ // column even after a resize reshuffles VLs.
1315
+ let currentVisualCol: number;
1316
+ if (this.snappedFromCursorCol !== null) {
1317
+ const vlIndex = this.findVisualLineAt(
1318
+ visualLines,
1319
+ currentVL.logicalLine,
1320
+ this.snappedFromCursorCol
1307
1321
  );
1322
+ currentVisualCol = this.snappedFromCursorCol - visualLines[vlIndex].startCol;
1323
+ } else {
1324
+ currentVisualCol = this.state.cursorCol - currentVL.startCol;
1325
+ }
1326
+
1327
+ // For non-last segments, clamp to length-1 to stay within the segment
1328
+ const isLastSourceSegment =
1329
+ currentVisualLine === visualLines.length - 1 ||
1330
+ visualLines[currentVisualLine + 1]?.logicalLine !== currentVL.logicalLine;
1331
+ const sourceMaxVisualCol = isLastSourceSegment
1332
+ ? currentVL.length
1333
+ : Math.max(0, currentVL.length - 1);
1334
+
1335
+ const isLastTargetSegment =
1336
+ targetVisualLine === visualLines.length - 1 ||
1337
+ visualLines[targetVisualLine + 1]?.logicalLine !== targetVL.logicalLine;
1338
+ const targetMaxVisualCol = isLastTargetSegment
1339
+ ? targetVL.length
1340
+ : Math.max(0, targetVL.length - 1);
1341
+
1342
+ const moveToVisualCol = this.computeVerticalMoveColumn(
1343
+ currentVisualCol,
1344
+ sourceMaxVisualCol,
1345
+ targetMaxVisualCol
1346
+ );
1347
+
1348
+ // Set cursor position
1349
+ this.state.cursorLine = targetVL.logicalLine;
1350
+ const targetCol = targetVL.startCol + moveToVisualCol;
1351
+ const logicalLine = this.state.lines[targetVL.logicalLine] || "";
1352
+ this.state.cursorCol = Math.min(targetCol, logicalLine.length);
1353
+
1354
+ // Snap cursor to atomic segment boundary (e.g. paste markers)
1355
+ // so the cursor never lands in the middle of a multi-grapheme unit.
1356
+ // Single-grapheme segments don't need snapping.
1357
+ const segments = [...this.segment(logicalLine)];
1358
+ for (const seg of segments) {
1359
+ if (seg.index > this.state.cursorCol) break;
1360
+ if (seg.segment.length <= 1) continue;
1361
+ if (this.state.cursorCol < seg.index + seg.segment.length) {
1362
+ const isContinuation = seg.index < targetVL.startCol;
1363
+ const isMovingDown = targetVisualLine > currentVisualLine;
1364
+
1365
+ if (isContinuation && isMovingDown) {
1366
+ // The segment started on a previous visual line, and we
1367
+ // already visited it on the way down. Skip all remaining
1368
+ // continuation VLs and land on the first VL past it.
1369
+ const segEnd = seg.index + seg.segment.length;
1370
+ let next = targetVisualLine + 1;
1371
+ while (
1372
+ next < visualLines.length &&
1373
+ visualLines[next].logicalLine === targetVL.logicalLine &&
1374
+ visualLines[next].startCol < segEnd
1375
+ ) {
1376
+ next++;
1377
+ }
1378
+ if (next < visualLines.length) {
1379
+ this.moveToVisualLine(visualLines, currentVisualLine, next);
1380
+ return;
1381
+ }
1382
+ }
1308
1383
 
1309
- // Set cursor position
1310
- this.state.cursorLine = targetVL.logicalLine;
1311
- const targetCol = targetVL.startCol + moveToVisualCol;
1312
- const logicalLine = this.state.lines[targetVL.logicalLine] || "";
1313
- this.state.cursorCol = Math.min(targetCol, logicalLine.length);
1384
+ // Snap to the start of the segment so it gets highlighted.
1385
+ // Store the pre-snap position so the next vertical move can
1386
+ // resolve it to the correct visual column.
1387
+ this.snappedFromCursorCol = this.state.cursorCol;
1388
+ this.state.cursorCol = seg.index;
1389
+ return;
1390
+ }
1314
1391
  }
1392
+
1393
+ // No snap occurred – we moved out of the atomic segment.
1394
+ this.snappedFromCursorCol = null;
1315
1395
  }
1316
1396
 
1317
1397
  /**
@@ -1408,7 +1488,9 @@ export class Editor implements Component, Focusable {
1408
1488
  this.setCursorCol(previousLine.length);
1409
1489
  }
1410
1490
 
1411
- this.notifyChange();
1491
+ if (this.onChange) {
1492
+ this.onChange(this.getText());
1493
+ }
1412
1494
  }
1413
1495
 
1414
1496
  private deleteToEndOfLine(): void {
@@ -1438,7 +1520,9 @@ export class Editor implements Component, Focusable {
1438
1520
  this.state.lines.splice(this.state.cursorLine + 1, 1);
1439
1521
  }
1440
1522
 
1441
- this.notifyChange();
1523
+ if (this.onChange) {
1524
+ this.onChange(this.getText());
1525
+ }
1442
1526
  }
1443
1527
 
1444
1528
  private deleteWordBackwards(): void {
@@ -1481,7 +1565,9 @@ export class Editor implements Component, Focusable {
1481
1565
  this.setCursorCol(deleteFrom);
1482
1566
  }
1483
1567
 
1484
- this.notifyChange();
1568
+ if (this.onChange) {
1569
+ this.onChange(this.getText());
1570
+ }
1485
1571
  }
1486
1572
 
1487
1573
  private deleteWordForward(): void {
@@ -1521,13 +1607,14 @@ export class Editor implements Component, Focusable {
1521
1607
  currentLine.slice(0, this.state.cursorCol) + currentLine.slice(deleteTo);
1522
1608
  }
1523
1609
 
1524
- this.notifyChange();
1610
+ if (this.onChange) {
1611
+ this.onChange(this.getText());
1612
+ }
1525
1613
  }
1526
1614
 
1527
1615
  private handleForwardDelete(): void {
1528
1616
  this.historyIndex = -1; // Exit history browsing mode
1529
1617
  this.lastAction = null;
1530
- this.ghostTextValue = null;
1531
1618
 
1532
1619
  const currentLine = this.state.lines[this.state.cursorLine] || "";
1533
1620
 
@@ -1538,7 +1625,7 @@ export class Editor implements Component, Focusable {
1538
1625
  const afterCursor = currentLine.slice(this.state.cursorCol);
1539
1626
 
1540
1627
  // Find the first grapheme at cursor
1541
- const graphemes = [...segmenter.segment(afterCursor)];
1628
+ const graphemes = [...this.segment(afterCursor)];
1542
1629
  const firstGrapheme = graphemes[0];
1543
1630
  const graphemeLength = firstGrapheme ? firstGrapheme.segment.length : 1;
1544
1631
 
@@ -1554,7 +1641,9 @@ export class Editor implements Component, Focusable {
1554
1641
  this.state.lines.splice(this.state.cursorLine + 1, 1);
1555
1642
  }
1556
1643
 
1557
- this.notifyChange();
1644
+ if (this.onChange) {
1645
+ this.onChange(this.getText());
1646
+ }
1558
1647
 
1559
1648
  // Update or re-trigger autocomplete after forward delete
1560
1649
  if (this.autocompleteState) {
@@ -1595,7 +1684,7 @@ export class Editor implements Component, Focusable {
1595
1684
  visualLines.push({ logicalLine: i, startCol: 0, length: line.length });
1596
1685
  } else {
1597
1686
  // Line needs wrapping - use word-aware wrapping
1598
- const chunks = wordWrapLine(line, width);
1687
+ const chunks = wordWrapLine(line, width, [...this.segment(line)]);
1599
1688
  for (const chunk of chunks) {
1600
1689
  visualLines.push({
1601
1690
  logicalLine: i,
@@ -1610,32 +1699,37 @@ export class Editor implements Component, Focusable {
1610
1699
  }
1611
1700
 
1612
1701
  /**
1613
- * Find the visual line index for the current cursor position.
1702
+ * Find the visual line index that contains the given logical position.
1614
1703
  */
1615
- private findCurrentVisualLine(
1616
- visualLines: Array<{ logicalLine: number; startCol: number; length: number }>
1704
+ private findVisualLineAt(
1705
+ visualLines: Array<{ logicalLine: number; startCol: number; length: number }>,
1706
+ line: number,
1707
+ col: number
1617
1708
  ): number {
1618
1709
  for (let i = 0; i < visualLines.length; i++) {
1619
1710
  const vl = visualLines[i];
1620
- if (!vl) continue;
1621
- if (vl.logicalLine === this.state.cursorLine) {
1622
- const colInSegment = this.state.cursorCol - vl.startCol;
1623
- // Cursor is in this segment if it's within range
1624
- // For the last segment of a logical line, cursor can be at length (end position)
1625
- const isLastSegmentOfLine =
1626
- i === visualLines.length - 1 || visualLines[i + 1]?.logicalLine !== vl.logicalLine;
1627
- if (
1628
- colInSegment >= 0 &&
1629
- (colInSegment < vl.length || (isLastSegmentOfLine && colInSegment <= vl.length))
1630
- ) {
1631
- return i;
1632
- }
1711
+ if (!vl || vl.logicalLine !== line) continue;
1712
+ const offset = col - vl.startCol;
1713
+ // Cursor is in this segment if it's within range. For the last
1714
+ // segment of a logical line, cursor can be at length (end position)
1715
+ const isLastSegmentOfLine =
1716
+ i === visualLines.length - 1 || visualLines[i + 1]?.logicalLine !== vl.logicalLine;
1717
+ if (offset >= 0 && (offset < vl.length || (isLastSegmentOfLine && offset === vl.length))) {
1718
+ return i;
1633
1719
  }
1634
1720
  }
1635
- // Fallback: return last visual line
1636
1721
  return visualLines.length - 1;
1637
1722
  }
1638
1723
 
1724
+ /**
1725
+ * Find the visual line index for the current cursor position.
1726
+ */
1727
+ private findCurrentVisualLine(
1728
+ visualLines: Array<{ logicalLine: number; startCol: number; length: number }>
1729
+ ): number {
1730
+ return this.findVisualLineAt(visualLines, this.state.cursorLine, this.state.cursorCol);
1731
+ }
1732
+
1639
1733
  private moveCursor(deltaLine: number, deltaCol: number): void {
1640
1734
  this.lastAction = null;
1641
1735
  const visualLines = this.buildVisualLineMap(this.lastWidth);
@@ -1656,7 +1750,7 @@ export class Editor implements Component, Focusable {
1656
1750
  // Moving right - move by one grapheme (handles emojis, combining characters, etc.)
1657
1751
  if (this.state.cursorCol < currentLine.length) {
1658
1752
  const afterCursor = currentLine.slice(this.state.cursorCol);
1659
- const graphemes = [...segmenter.segment(afterCursor)];
1753
+ const graphemes = [...this.segment(afterCursor)];
1660
1754
  const firstGrapheme = graphemes[0];
1661
1755
  this.setCursorCol(
1662
1756
  this.state.cursorCol + (firstGrapheme ? firstGrapheme.segment.length : 1)
@@ -1676,7 +1770,7 @@ export class Editor implements Component, Focusable {
1676
1770
  // Moving left - move by one grapheme (handles emojis, combining characters, etc.)
1677
1771
  if (this.state.cursorCol > 0) {
1678
1772
  const beforeCursor = currentLine.slice(0, this.state.cursorCol);
1679
- const graphemes = [...segmenter.segment(beforeCursor)];
1773
+ const graphemes = [...this.segment(beforeCursor)];
1680
1774
  const lastGrapheme = graphemes[graphemes.length - 1];
1681
1775
  this.setCursorCol(
1682
1776
  this.state.cursorCol - (lastGrapheme ? lastGrapheme.segment.length : 1)
@@ -1725,12 +1819,13 @@ export class Editor implements Component, Focusable {
1725
1819
  }
1726
1820
 
1727
1821
  const textBeforeCursor = currentLine.slice(0, this.state.cursorCol);
1728
- const graphemes = [...segmenter.segment(textBeforeCursor)];
1822
+ const graphemes = [...this.segment(textBeforeCursor)];
1729
1823
  let newCol = this.state.cursorCol;
1730
1824
 
1731
1825
  // Skip trailing whitespace
1732
1826
  while (
1733
1827
  graphemes.length > 0 &&
1828
+ !isPasteMarker(graphemes[graphemes.length - 1]?.segment || "") &&
1734
1829
  isWhitespaceChar(graphemes[graphemes.length - 1]?.segment || "")
1735
1830
  ) {
1736
1831
  newCol -= graphemes.pop()?.segment.length || 0;
@@ -1738,11 +1833,15 @@ export class Editor implements Component, Focusable {
1738
1833
 
1739
1834
  if (graphemes.length > 0) {
1740
1835
  const lastGrapheme = graphemes[graphemes.length - 1]?.segment || "";
1741
- if (isPunctuationChar(lastGrapheme)) {
1836
+ if (isPasteMarker(lastGrapheme)) {
1837
+ // Paste marker is a single atomic word
1838
+ newCol -= graphemes.pop()?.segment.length || 0;
1839
+ } else if (isPunctuationChar(lastGrapheme)) {
1742
1840
  // Skip punctuation run
1743
1841
  while (
1744
1842
  graphemes.length > 0 &&
1745
- isPunctuationChar(graphemes[graphemes.length - 1]?.segment || "")
1843
+ isPunctuationChar(graphemes[graphemes.length - 1]?.segment || "") &&
1844
+ !isPasteMarker(graphemes[graphemes.length - 1]?.segment || "")
1746
1845
  ) {
1747
1846
  newCol -= graphemes.pop()?.segment.length || 0;
1748
1847
  }
@@ -1751,7 +1850,8 @@ export class Editor implements Component, Focusable {
1751
1850
  while (
1752
1851
  graphemes.length > 0 &&
1753
1852
  !isWhitespaceChar(graphemes[graphemes.length - 1]?.segment || "") &&
1754
- !isPunctuationChar(graphemes[graphemes.length - 1]?.segment || "")
1853
+ !isPunctuationChar(graphemes[graphemes.length - 1]?.segment || "") &&
1854
+ !isPasteMarker(graphemes[graphemes.length - 1]?.segment || "")
1755
1855
  ) {
1756
1856
  newCol -= graphemes.pop()?.segment.length || 0;
1757
1857
  }
@@ -1835,7 +1935,9 @@ export class Editor implements Component, Focusable {
1835
1935
  this.setCursorCol((lines[lines.length - 1] || "").length);
1836
1936
  }
1837
1937
 
1838
- this.notifyChange();
1938
+ if (this.onChange) {
1939
+ this.onChange(this.getText());
1940
+ }
1839
1941
  }
1840
1942
 
1841
1943
  /**
@@ -1877,7 +1979,9 @@ export class Editor implements Component, Focusable {
1877
1979
  this.setCursorCol(startCol);
1878
1980
  }
1879
1981
 
1880
- this.notifyChange();
1982
+ if (this.onChange) {
1983
+ this.onChange(this.getText());
1984
+ }
1881
1985
  }
1882
1986
 
1883
1987
  private pushUndoSnapshot(): void {
@@ -1891,7 +1995,9 @@ export class Editor implements Component, Focusable {
1891
1995
  Object.assign(this.state, snapshot);
1892
1996
  this.lastAction = null;
1893
1997
  this.preferredVisualCol = null;
1894
- this.notifyChange();
1998
+ if (this.onChange) {
1999
+ this.onChange(this.getText());
2000
+ }
1895
2001
  }
1896
2002
 
1897
2003
  /**
@@ -1942,22 +2048,33 @@ export class Editor implements Component, Focusable {
1942
2048
  }
1943
2049
 
1944
2050
  const textAfterCursor = currentLine.slice(this.state.cursorCol);
1945
- const segments = segmenter.segment(textAfterCursor);
2051
+ const segments = this.segment(textAfterCursor);
1946
2052
  const iterator = segments[Symbol.iterator]();
1947
2053
  let next = iterator.next();
1948
2054
  let newCol = this.state.cursorCol;
1949
2055
 
1950
2056
  // Skip leading whitespace
1951
- while (!next.done && isWhitespaceChar(next.value.segment)) {
2057
+ while (
2058
+ !next.done &&
2059
+ !isPasteMarker(next.value.segment) &&
2060
+ isWhitespaceChar(next.value.segment)
2061
+ ) {
1952
2062
  newCol += next.value.segment.length;
1953
2063
  next = iterator.next();
1954
2064
  }
1955
2065
 
1956
2066
  if (!next.done) {
1957
2067
  const firstGrapheme = next.value.segment;
1958
- if (isPunctuationChar(firstGrapheme)) {
2068
+ if (isPasteMarker(firstGrapheme)) {
2069
+ // Paste marker is a single atomic word
2070
+ newCol += firstGrapheme.length;
2071
+ } else if (isPunctuationChar(firstGrapheme)) {
1959
2072
  // Skip punctuation run
1960
- while (!next.done && isPunctuationChar(next.value.segment)) {
2073
+ while (
2074
+ !next.done &&
2075
+ isPunctuationChar(next.value.segment) &&
2076
+ !isPasteMarker(next.value.segment)
2077
+ ) {
1961
2078
  newCol += next.value.segment.length;
1962
2079
  next = iterator.next();
1963
2080
  }
@@ -1966,7 +2083,8 @@ export class Editor implements Component, Focusable {
1966
2083
  while (
1967
2084
  !next.done &&
1968
2085
  !isWhitespaceChar(next.value.segment) &&
1969
- !isPunctuationChar(next.value.segment)
2086
+ !isPunctuationChar(next.value.segment) &&
2087
+ !isPasteMarker(next.value.segment)
1970
2088
  ) {
1971
2089
  newCol += next.value.segment.length;
1972
2090
  next = iterator.next();
@@ -1995,41 +2113,48 @@ export class Editor implements Component, Focusable {
1995
2113
  }
1996
2114
 
1997
2115
  // Autocomplete methods
1998
- private tryTriggerAutocomplete(explicitTab: boolean = false): void {
1999
- if (!this.autocompleteProvider) return;
2116
+ /**
2117
+ * Find the best autocomplete item index for the given prefix.
2118
+ * Returns -1 if no match is found.
2119
+ *
2120
+ * Match priority:
2121
+ * 1. Exact match (prefix === item.value) -> always selected
2122
+ * 2. Prefix match -> first item whose value starts with prefix
2123
+ * 3. No match -> -1 (keep default highlight)
2124
+ *
2125
+ * Matching is case-sensitive and checks item.value only.
2126
+ */
2127
+ private getBestAutocompleteMatchIndex(
2128
+ items: Array<{ value: string; label: string }>,
2129
+ prefix: string
2130
+ ): number {
2131
+ if (!prefix) return -1;
2000
2132
 
2001
- // Check if we should trigger file completion on Tab
2002
- if (explicitTab) {
2003
- const provider = this.autocompleteProvider as CombinedAutocompleteProvider;
2004
- const shouldTrigger =
2005
- !provider.shouldTriggerFileCompletion ||
2006
- provider.shouldTriggerFileCompletion(
2007
- this.state.lines,
2008
- this.state.cursorLine,
2009
- this.state.cursorCol
2010
- );
2011
- if (!shouldTrigger) {
2012
- return;
2133
+ let firstPrefixIndex = -1;
2134
+
2135
+ for (let i = 0; i < items.length; i++) {
2136
+ const value = items[i]!.value;
2137
+ if (value === prefix) {
2138
+ return i; // Exact match always wins
2139
+ }
2140
+ if (firstPrefixIndex === -1 && value.startsWith(prefix)) {
2141
+ firstPrefixIndex = i;
2013
2142
  }
2014
2143
  }
2015
2144
 
2016
- const suggestions = this.autocompleteProvider.getSuggestions(
2017
- this.state.lines,
2018
- this.state.cursorLine,
2019
- this.state.cursorCol
2020
- );
2145
+ return firstPrefixIndex;
2146
+ }
2021
2147
 
2022
- if (suggestions && suggestions.items.length > 0) {
2023
- this.autocompletePrefix = suggestions.prefix;
2024
- this.autocompleteList = new SelectList(
2025
- suggestions.items,
2026
- this.autocompleteMaxVisible,
2027
- this.theme.selectList
2028
- );
2029
- this.autocompleteState = "regular";
2030
- } else {
2031
- this.cancelAutocomplete();
2032
- }
2148
+ private createAutocompleteList(
2149
+ prefix: string,
2150
+ items: Array<{ value: string; label: string; description?: string }>
2151
+ ): SelectList {
2152
+ const layout = prefix.startsWith("/") ? SLASH_COMMAND_SELECT_LIST_LAYOUT : undefined;
2153
+ return new SelectList(items, this.autocompleteMaxVisible, this.theme.selectList, layout);
2154
+ }
2155
+
2156
+ private tryTriggerAutocomplete(explicitTab: boolean = false): void {
2157
+ this.requestAutocomplete({ force: false, explicitTab });
2033
2158
  }
2034
2159
 
2035
2160
  private handleTabCompletion(): void {
@@ -2038,7 +2163,6 @@ export class Editor implements Component, Focusable {
2038
2163
  const currentLine = this.state.lines[this.state.cursorLine] || "";
2039
2164
  const beforeCursor = currentLine.slice(0, this.state.cursorCol);
2040
2165
 
2041
- // Check if we're in a slash command context
2042
2166
  if (this.isInSlashCommandContext(beforeCursor) && !beforeCursor.trimStart().includes(" ")) {
2043
2167
  this.handleSlashCommandCompletion();
2044
2168
  } else {
@@ -2047,97 +2171,207 @@ export class Editor implements Component, Focusable {
2047
2171
  }
2048
2172
 
2049
2173
  private handleSlashCommandCompletion(): void {
2050
- this.tryTriggerAutocomplete(true);
2174
+ this.requestAutocomplete({ force: false, explicitTab: true });
2051
2175
  }
2052
2176
 
2053
- /*
2054
- https://github.com/EsotericSoftware/spine-runtimes/actions/runs/19536643416/job/559322883
2055
- 17 this job fails with https://github.com/EsotericSoftware/spine-runtimes/actions/runs/19
2056
- 536643416/job/55932288317 havea look at .gi
2057
- */
2058
2177
  private forceFileAutocomplete(explicitTab: boolean = false): void {
2178
+ this.requestAutocomplete({ force: true, explicitTab });
2179
+ }
2180
+
2181
+ private requestAutocomplete(options: { force: boolean; explicitTab: boolean }): void {
2059
2182
  if (!this.autocompleteProvider) return;
2060
2183
 
2061
- // Check if provider supports force file suggestions via runtime check
2062
- const provider = this.autocompleteProvider as {
2063
- getForceFileSuggestions?: CombinedAutocompleteProvider["getForceFileSuggestions"];
2064
- };
2065
- if (typeof provider.getForceFileSuggestions !== "function") {
2066
- this.tryTriggerAutocomplete(true);
2184
+ if (options.force) {
2185
+ const provider = this.autocompleteProvider as CombinedAutocompleteProvider;
2186
+ const shouldTrigger =
2187
+ !provider.shouldTriggerFileCompletion ||
2188
+ provider.shouldTriggerFileCompletion(
2189
+ this.state.lines,
2190
+ this.state.cursorLine,
2191
+ this.state.cursorCol
2192
+ );
2193
+ if (!shouldTrigger) {
2194
+ return;
2195
+ }
2196
+ }
2197
+
2198
+ this.cancelAutocompleteRequest();
2199
+ const startToken = ++this.autocompleteStartToken;
2200
+
2201
+ const debounceMs = this.getAutocompleteDebounceMs(options);
2202
+ if (debounceMs > 0) {
2203
+ this.autocompleteDebounceTimer = setTimeout(() => {
2204
+ this.autocompleteDebounceTimer = undefined;
2205
+ void this.startAutocompleteRequest(startToken, options);
2206
+ }, debounceMs);
2067
2207
  return;
2068
2208
  }
2069
2209
 
2070
- const suggestions = provider.getForceFileSuggestions(
2071
- this.state.lines,
2072
- this.state.cursorLine,
2073
- this.state.cursorCol
2074
- );
2210
+ void this.startAutocompleteRequest(startToken, options);
2211
+ }
2075
2212
 
2076
- if (suggestions && suggestions.items.length > 0) {
2077
- // If there's exactly one suggestion, apply it immediately
2078
- if (explicitTab && suggestions.items.length === 1) {
2079
- const item = suggestions.items[0]!;
2080
- this.pushUndoSnapshot();
2081
- this.lastAction = null;
2082
- const result = this.autocompleteProvider.applyCompletion(
2083
- this.state.lines,
2084
- this.state.cursorLine,
2085
- this.state.cursorCol,
2086
- item,
2087
- suggestions.prefix
2088
- );
2089
- this.state.lines = result.lines;
2090
- this.state.cursorLine = result.cursorLine;
2091
- this.setCursorCol(result.cursorCol);
2092
- this.notifyChange();
2213
+ private async startAutocompleteRequest(
2214
+ startToken: number,
2215
+ options: { force: boolean; explicitTab: boolean }
2216
+ ): Promise<void> {
2217
+ const previousTask = this.autocompleteRequestTask;
2218
+ this.autocompleteRequestTask = (async () => {
2219
+ await previousTask;
2220
+ if (startToken !== this.autocompleteStartToken || !this.autocompleteProvider) {
2093
2221
  return;
2094
2222
  }
2095
2223
 
2096
- this.autocompletePrefix = suggestions.prefix;
2097
- this.autocompleteList = new SelectList(
2098
- suggestions.items,
2099
- this.autocompleteMaxVisible,
2100
- this.theme.selectList
2224
+ const controller = new AbortController();
2225
+ this.autocompleteAbort = controller;
2226
+ const requestId = ++this.autocompleteRequestId;
2227
+ const snapshotText = this.getText();
2228
+ const snapshotLine = this.state.cursorLine;
2229
+ const snapshotCol = this.state.cursorCol;
2230
+
2231
+ await this.runAutocompleteRequest(
2232
+ requestId,
2233
+ controller,
2234
+ snapshotText,
2235
+ snapshotLine,
2236
+ snapshotCol,
2237
+ options
2101
2238
  );
2102
- this.autocompleteState = "force";
2103
- } else {
2239
+ })();
2240
+ await this.autocompleteRequestTask;
2241
+ }
2242
+
2243
+ private getAutocompleteDebounceMs(options: { force: boolean; explicitTab: boolean }): number {
2244
+ if (options.explicitTab || options.force) {
2245
+ return 0;
2246
+ }
2247
+
2248
+ const currentLine = this.state.lines[this.state.cursorLine] || "";
2249
+ const textBeforeCursor = currentLine.slice(0, this.state.cursorCol);
2250
+ const isAttachmentContext = /(?:^|[ \t])@(?:"[^"]*|[^\s]*)$/.test(textBeforeCursor);
2251
+ return isAttachmentContext ? ATTACHMENT_AUTOCOMPLETE_DEBOUNCE_MS : 0;
2252
+ }
2253
+
2254
+ private async runAutocompleteRequest(
2255
+ requestId: number,
2256
+ controller: AbortController,
2257
+ snapshotText: string,
2258
+ snapshotLine: number,
2259
+ snapshotCol: number,
2260
+ options: { force: boolean; explicitTab: boolean }
2261
+ ): Promise<void> {
2262
+ if (!this.autocompleteProvider) return;
2263
+
2264
+ const suggestions = await this.autocompleteProvider.getSuggestions(
2265
+ this.state.lines,
2266
+ this.state.cursorLine,
2267
+ this.state.cursorCol,
2268
+ { signal: controller.signal, force: options.force }
2269
+ );
2270
+
2271
+ if (
2272
+ !this.isAutocompleteRequestCurrent(
2273
+ requestId,
2274
+ controller,
2275
+ snapshotText,
2276
+ snapshotLine,
2277
+ snapshotCol
2278
+ )
2279
+ ) {
2280
+ return;
2281
+ }
2282
+
2283
+ this.autocompleteAbort = undefined;
2284
+
2285
+ if (!suggestions || !Array.isArray(suggestions.items) || suggestions.items.length === 0) {
2104
2286
  this.cancelAutocomplete();
2287
+ this.tui.requestRender();
2288
+ return;
2105
2289
  }
2290
+
2291
+ if (options.force && options.explicitTab && suggestions.items.length === 1) {
2292
+ const item = suggestions.items[0]!;
2293
+ this.pushUndoSnapshot();
2294
+ this.lastAction = null;
2295
+ const result = this.autocompleteProvider.applyCompletion(
2296
+ this.state.lines,
2297
+ this.state.cursorLine,
2298
+ this.state.cursorCol,
2299
+ item,
2300
+ suggestions.prefix
2301
+ );
2302
+ this.state.lines = result.lines;
2303
+ this.state.cursorLine = result.cursorLine;
2304
+ this.setCursorCol(result.cursorCol);
2305
+ if (this.onChange) this.onChange(this.getText());
2306
+ this.tui.requestRender();
2307
+ return;
2308
+ }
2309
+
2310
+ this.applyAutocompleteSuggestions(suggestions, options.force ? "force" : "regular");
2311
+ this.tui.requestRender();
2106
2312
  }
2107
2313
 
2108
- private cancelAutocomplete(): void {
2314
+ private isAutocompleteRequestCurrent(
2315
+ requestId: number,
2316
+ controller: AbortController,
2317
+ snapshotText: string,
2318
+ snapshotLine: number,
2319
+ snapshotCol: number
2320
+ ): boolean {
2321
+ return (
2322
+ !controller.signal.aborted &&
2323
+ requestId === this.autocompleteRequestId &&
2324
+ this.getText() === snapshotText &&
2325
+ this.state.cursorLine === snapshotLine &&
2326
+ this.state.cursorCol === snapshotCol
2327
+ );
2328
+ }
2329
+
2330
+ private applyAutocompleteSuggestions(
2331
+ suggestions: AutocompleteSuggestions,
2332
+ state: "regular" | "force"
2333
+ ): void {
2334
+ this.autocompletePrefix = suggestions.prefix;
2335
+ this.autocompleteList = this.createAutocompleteList(suggestions.prefix, suggestions.items);
2336
+
2337
+ const bestMatchIndex = this.getBestAutocompleteMatchIndex(
2338
+ suggestions.items,
2339
+ suggestions.prefix
2340
+ );
2341
+ if (bestMatchIndex >= 0) {
2342
+ this.autocompleteList.setSelectedIndex(bestMatchIndex);
2343
+ }
2344
+
2345
+ this.autocompleteState = state;
2346
+ }
2347
+
2348
+ private cancelAutocompleteRequest(): void {
2349
+ this.autocompleteStartToken += 1;
2350
+ if (this.autocompleteDebounceTimer) {
2351
+ clearTimeout(this.autocompleteDebounceTimer);
2352
+ this.autocompleteDebounceTimer = undefined;
2353
+ }
2354
+ this.autocompleteAbort?.abort();
2355
+ this.autocompleteAbort = undefined;
2356
+ }
2357
+
2358
+ private clearAutocompleteUi(): void {
2109
2359
  this.autocompleteState = null;
2110
2360
  this.autocompleteList = undefined;
2111
2361
  this.autocompletePrefix = "";
2112
2362
  }
2113
2363
 
2364
+ private cancelAutocomplete(): void {
2365
+ this.cancelAutocompleteRequest();
2366
+ this.clearAutocompleteUi();
2367
+ }
2368
+
2114
2369
  public isShowingAutocomplete(): boolean {
2115
2370
  return this.autocompleteState !== null;
2116
2371
  }
2117
2372
 
2118
2373
  private updateAutocomplete(): void {
2119
2374
  if (!this.autocompleteState || !this.autocompleteProvider) return;
2120
-
2121
- if (this.autocompleteState === "force") {
2122
- this.forceFileAutocomplete();
2123
- return;
2124
- }
2125
-
2126
- const suggestions = this.autocompleteProvider.getSuggestions(
2127
- this.state.lines,
2128
- this.state.cursorLine,
2129
- this.state.cursorCol
2130
- );
2131
- if (suggestions && suggestions.items.length > 0) {
2132
- this.autocompletePrefix = suggestions.prefix;
2133
- // Always create new SelectList to ensure update
2134
- this.autocompleteList = new SelectList(
2135
- suggestions.items,
2136
- this.autocompleteMaxVisible,
2137
- this.theme.selectList
2138
- );
2139
- } else {
2140
- this.cancelAutocomplete();
2141
- }
2375
+ this.requestAutocomplete({ force: this.autocompleteState === "force", explicitTab: false });
2142
2376
  }
2143
2377
  }