@dungle-scrubs/tallow 0.9.4 → 0.9.6

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 (195) hide show
  1. package/dist/cli.js +7 -4
  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/reset-diagnostics.d.ts +69 -0
  26. package/dist/reset-diagnostics.d.ts.map +1 -0
  27. package/dist/reset-diagnostics.js +41 -0
  28. package/dist/reset-diagnostics.js.map +1 -0
  29. package/dist/sdk.d.ts +5 -21
  30. package/dist/sdk.d.ts.map +1 -1
  31. package/dist/sdk.js +180 -149
  32. package/dist/sdk.js.map +1 -1
  33. package/dist/workspace-transition-interactive.d.ts +1 -0
  34. package/dist/workspace-transition-interactive.d.ts.map +1 -1
  35. package/dist/workspace-transition-interactive.js +7 -17
  36. package/dist/workspace-transition-interactive.js.map +1 -1
  37. package/extensions/__integration__/audit-findings.test.ts +4 -5
  38. package/extensions/_icons/index.ts +2 -4
  39. package/extensions/_shared/__tests__/image-metadata.test.ts +33 -0
  40. package/extensions/_shared/__tests__/terminal-links.test.ts +18 -0
  41. package/extensions/_shared/image-metadata.ts +99 -0
  42. package/extensions/_shared/inline-preview.ts +1 -1
  43. package/extensions/_shared/terminal-links.ts +22 -0
  44. package/extensions/ask-user-question-tool/index.ts +0 -3
  45. package/extensions/clear/__tests__/clear.test.ts +269 -2
  46. package/extensions/command-expansion/index.ts +1 -1
  47. package/extensions/context-files/index.ts +5 -1
  48. package/extensions/context-fork/__tests__/context-fork.test.ts +94 -1
  49. package/extensions/context-fork/extension.json +1 -1
  50. package/extensions/context-fork/index.ts +32 -0
  51. package/extensions/edit-tool-enhanced/index.ts +2 -1
  52. package/extensions/hooks/index.ts +33 -11
  53. package/extensions/loop/index.ts +14 -1
  54. package/extensions/lsp/index.ts +64 -13
  55. package/extensions/lsp/package.json +2 -2
  56. package/extensions/random-spinner/index.ts +7 -642
  57. package/extensions/read-tool-enhanced/index.ts +6 -8
  58. package/extensions/render-stabilizer/__tests__/render-stabilizer.test.ts +2 -3
  59. package/extensions/render-stabilizer/index.ts +6 -6
  60. package/extensions/slash-command-bridge/__tests__/slash-command-bridge.test.ts +26 -0
  61. package/extensions/slash-command-bridge/index.ts +14 -2
  62. package/extensions/subagent-tool/model-resolver.ts +274 -7
  63. package/extensions/tasks/commands/register-tasks-extension.ts +9 -9
  64. package/extensions/teams-tool/tools/register-extension.ts +1 -3
  65. package/extensions/web-search-tool/index.ts +2 -1
  66. package/extensions/write-tool-enhanced/index.ts +2 -1
  67. package/node_modules/@mariozechner/pi-tui/README.md +56 -34
  68. package/node_modules/@mariozechner/pi-tui/dist/autocomplete.d.ts +18 -13
  69. package/node_modules/@mariozechner/pi-tui/dist/autocomplete.d.ts.map +1 -1
  70. package/node_modules/@mariozechner/pi-tui/dist/autocomplete.js +182 -113
  71. package/node_modules/@mariozechner/pi-tui/dist/autocomplete.js.map +1 -1
  72. package/node_modules/@mariozechner/pi-tui/dist/components/cancellable-loader.js +3 -3
  73. package/node_modules/@mariozechner/pi-tui/dist/components/cancellable-loader.js.map +1 -1
  74. package/node_modules/@mariozechner/pi-tui/dist/components/editor.d.ts +45 -36
  75. package/node_modules/@mariozechner/pi-tui/dist/components/editor.d.ts.map +1 -1
  76. package/node_modules/@mariozechner/pi-tui/dist/components/editor.js +489 -325
  77. package/node_modules/@mariozechner/pi-tui/dist/components/editor.js.map +1 -1
  78. package/node_modules/@mariozechner/pi-tui/dist/components/image.d.ts +1 -99
  79. package/node_modules/@mariozechner/pi-tui/dist/components/image.d.ts.map +1 -1
  80. package/node_modules/@mariozechner/pi-tui/dist/components/image.js +17 -192
  81. package/node_modules/@mariozechner/pi-tui/dist/components/image.js.map +1 -1
  82. package/node_modules/@mariozechner/pi-tui/dist/components/input.d.ts.map +1 -1
  83. package/node_modules/@mariozechner/pi-tui/dist/components/input.js +57 -60
  84. package/node_modules/@mariozechner/pi-tui/dist/components/input.js.map +1 -1
  85. package/node_modules/@mariozechner/pi-tui/dist/components/loader.d.ts +2 -69
  86. package/node_modules/@mariozechner/pi-tui/dist/components/loader.d.ts.map +1 -1
  87. package/node_modules/@mariozechner/pi-tui/dist/components/loader.js +5 -102
  88. package/node_modules/@mariozechner/pi-tui/dist/components/loader.js.map +1 -1
  89. package/node_modules/@mariozechner/pi-tui/dist/components/markdown.d.ts.map +1 -1
  90. package/node_modules/@mariozechner/pi-tui/dist/components/markdown.js +111 -53
  91. package/node_modules/@mariozechner/pi-tui/dist/components/markdown.js.map +1 -1
  92. package/node_modules/@mariozechner/pi-tui/dist/components/select-list.d.ts +19 -1
  93. package/node_modules/@mariozechner/pi-tui/dist/components/select-list.d.ts.map +1 -1
  94. package/node_modules/@mariozechner/pi-tui/dist/components/select-list.js +78 -67
  95. package/node_modules/@mariozechner/pi-tui/dist/components/select-list.js.map +1 -1
  96. package/node_modules/@mariozechner/pi-tui/dist/components/settings-list.d.ts +1 -25
  97. package/node_modules/@mariozechner/pi-tui/dist/components/settings-list.d.ts.map +1 -1
  98. package/node_modules/@mariozechner/pi-tui/dist/components/settings-list.js +13 -50
  99. package/node_modules/@mariozechner/pi-tui/dist/components/settings-list.js.map +1 -1
  100. package/node_modules/@mariozechner/pi-tui/dist/index.d.ts +8 -10
  101. package/node_modules/@mariozechner/pi-tui/dist/index.d.ts.map +1 -1
  102. package/node_modules/@mariozechner/pi-tui/dist/index.js +6 -9
  103. package/node_modules/@mariozechner/pi-tui/dist/index.js.map +1 -1
  104. package/node_modules/@mariozechner/pi-tui/dist/keybindings.d.ts +108 -238
  105. package/node_modules/@mariozechner/pi-tui/dist/keybindings.d.ts.map +1 -1
  106. package/node_modules/@mariozechner/pi-tui/dist/keybindings.js +108 -365
  107. package/node_modules/@mariozechner/pi-tui/dist/keybindings.js.map +1 -1
  108. package/node_modules/@mariozechner/pi-tui/dist/keys.d.ts +33 -48
  109. package/node_modules/@mariozechner/pi-tui/dist/keys.d.ts.map +1 -1
  110. package/node_modules/@mariozechner/pi-tui/dist/keys.js +239 -155
  111. package/node_modules/@mariozechner/pi-tui/dist/keys.js.map +1 -1
  112. package/node_modules/@mariozechner/pi-tui/dist/terminal-image.d.ts +14 -94
  113. package/node_modules/@mariozechner/pi-tui/dist/terminal-image.d.ts.map +1 -1
  114. package/node_modules/@mariozechner/pi-tui/dist/terminal-image.js +44 -186
  115. package/node_modules/@mariozechner/pi-tui/dist/terminal-image.js.map +1 -1
  116. package/node_modules/@mariozechner/pi-tui/dist/terminal.d.ts +13 -58
  117. package/node_modules/@mariozechner/pi-tui/dist/terminal.d.ts.map +1 -1
  118. package/node_modules/@mariozechner/pi-tui/dist/terminal.js +78 -111
  119. package/node_modules/@mariozechner/pi-tui/dist/terminal.js.map +1 -1
  120. package/node_modules/@mariozechner/pi-tui/dist/tui.d.ts +24 -110
  121. package/node_modules/@mariozechner/pi-tui/dist/tui.d.ts.map +1 -1
  122. package/node_modules/@mariozechner/pi-tui/dist/tui.js +188 -435
  123. package/node_modules/@mariozechner/pi-tui/dist/tui.js.map +1 -1
  124. package/node_modules/@mariozechner/pi-tui/dist/utils.d.ts +0 -18
  125. package/node_modules/@mariozechner/pi-tui/dist/utils.d.ts.map +1 -1
  126. package/node_modules/@mariozechner/pi-tui/dist/utils.js +251 -119
  127. package/node_modules/@mariozechner/pi-tui/dist/utils.js.map +1 -1
  128. package/node_modules/@mariozechner/pi-tui/package.json +6 -6
  129. package/node_modules/@mariozechner/pi-tui/src/__tests__/__snapshots__/render.test.ts.snap +3 -40
  130. package/node_modules/@mariozechner/pi-tui/src/__tests__/image-component.test.ts +71 -81
  131. package/node_modules/@mariozechner/pi-tui/src/__tests__/render.test.ts +0 -33
  132. package/node_modules/@mariozechner/pi-tui/src/__tests__/terminal-image.test.ts +93 -334
  133. package/node_modules/@mariozechner/pi-tui/src/__tests__/tui-render-scheduling.test.ts +1 -1
  134. package/node_modules/@mariozechner/pi-tui/src/__tests__/utils.test.ts +11 -196
  135. package/node_modules/@mariozechner/pi-tui/src/autocomplete.ts +228 -142
  136. package/node_modules/@mariozechner/pi-tui/src/components/cancellable-loader.ts +3 -3
  137. package/node_modules/@mariozechner/pi-tui/src/components/editor.ts +624 -390
  138. package/node_modules/@mariozechner/pi-tui/src/components/image.ts +17 -227
  139. package/node_modules/@mariozechner/pi-tui/src/components/input.ts +71 -63
  140. package/node_modules/@mariozechner/pi-tui/src/components/loader.ts +5 -137
  141. package/node_modules/@mariozechner/pi-tui/src/components/markdown.ts +143 -52
  142. package/node_modules/@mariozechner/pi-tui/src/components/select-list.ts +136 -70
  143. package/node_modules/@mariozechner/pi-tui/src/components/settings-list.ts +12 -51
  144. package/node_modules/@mariozechner/pi-tui/src/index.ts +17 -36
  145. package/node_modules/@mariozechner/pi-tui/src/keybindings.ts +148 -421
  146. package/node_modules/@mariozechner/pi-tui/src/keys.ts +253 -181
  147. package/node_modules/@mariozechner/pi-tui/src/terminal-image.ts +51 -252
  148. package/node_modules/@mariozechner/pi-tui/src/terminal.ts +78 -133
  149. package/node_modules/@mariozechner/pi-tui/src/tui.ts +202 -478
  150. package/node_modules/@mariozechner/pi-tui/src/utils.ts +289 -125
  151. package/node_modules/@mariozechner/pi-tui/tsconfig.build.json +1 -0
  152. package/package.json +13 -13
  153. package/packages/tallow-tui/node_modules/@types/mime-types/README.md +8 -2
  154. package/packages/tallow-tui/node_modules/@types/mime-types/index.d.ts +6 -0
  155. package/packages/tallow-tui/node_modules/@types/mime-types/package.json +9 -3
  156. package/packages/tallow-tui/node_modules/get-east-asian-width/lookup-data.js +18 -0
  157. package/packages/tallow-tui/node_modules/get-east-asian-width/lookup.js +116 -384
  158. package/packages/tallow-tui/node_modules/get-east-asian-width/package.json +5 -4
  159. package/packages/tallow-tui/node_modules/get-east-asian-width/utilities.js +24 -0
  160. package/packages/tallow-tui/node_modules/marked/README.md +5 -4
  161. package/packages/tallow-tui/node_modules/marked/bin/main.js +10 -8
  162. package/packages/tallow-tui/node_modules/marked/bin/marked.js +2 -1
  163. package/packages/tallow-tui/node_modules/marked/lib/marked.d.ts +156 -125
  164. package/packages/tallow-tui/node_modules/marked/lib/marked.esm.js +67 -2179
  165. package/packages/tallow-tui/node_modules/marked/lib/marked.esm.js.map +3 -3
  166. package/packages/tallow-tui/node_modules/marked/lib/marked.umd.js +67 -2201
  167. package/packages/tallow-tui/node_modules/marked/lib/marked.umd.js.map +3 -3
  168. package/packages/tallow-tui/node_modules/marked/man/marked.1 +4 -2
  169. package/packages/tallow-tui/node_modules/marked/man/marked.1.md +2 -1
  170. package/packages/tallow-tui/node_modules/marked/package.json +26 -34
  171. package/skills/tallow-expert/SKILL.md +1 -3
  172. package/node_modules/@mariozechner/pi-tui/dist/border-styles.d.ts +0 -32
  173. package/node_modules/@mariozechner/pi-tui/dist/border-styles.d.ts.map +0 -1
  174. package/node_modules/@mariozechner/pi-tui/dist/border-styles.js +0 -46
  175. package/node_modules/@mariozechner/pi-tui/dist/border-styles.js.map +0 -1
  176. package/node_modules/@mariozechner/pi-tui/dist/components/bordered-box.d.ts +0 -52
  177. package/node_modules/@mariozechner/pi-tui/dist/components/bordered-box.d.ts.map +0 -1
  178. package/node_modules/@mariozechner/pi-tui/dist/components/bordered-box.js +0 -89
  179. package/node_modules/@mariozechner/pi-tui/dist/components/bordered-box.js.map +0 -1
  180. package/node_modules/@mariozechner/pi-tui/dist/test-utils/capability-env.d.ts +0 -14
  181. package/node_modules/@mariozechner/pi-tui/dist/test-utils/capability-env.d.ts.map +0 -1
  182. package/node_modules/@mariozechner/pi-tui/dist/test-utils/capability-env.js +0 -55
  183. package/node_modules/@mariozechner/pi-tui/dist/test-utils/capability-env.js.map +0 -1
  184. package/node_modules/@mariozechner/pi-tui/src/__tests__/editor-change-listener.test.ts +0 -121
  185. package/node_modules/@mariozechner/pi-tui/src/__tests__/editor-ghost-text.test.ts +0 -112
  186. package/node_modules/@mariozechner/pi-tui/src/__tests__/mouse-events.test.ts +0 -134
  187. package/node_modules/@mariozechner/pi-tui/src/__tests__/settings-list.test.ts +0 -81
  188. package/node_modules/@mariozechner/pi-tui/src/__tests__/tui-diff-regression.test.ts +0 -555
  189. package/node_modules/@mariozechner/pi-tui/src/border-styles.ts +0 -60
  190. package/node_modules/@mariozechner/pi-tui/src/components/bordered-box.ts +0 -113
  191. package/node_modules/@mariozechner/pi-tui/src/test-utils/capability-env.ts +0 -56
  192. package/packages/tallow-tui/node_modules/marked/lib/marked.cjs +0 -2211
  193. package/packages/tallow-tui/node_modules/marked/lib/marked.cjs.map +0 -7
  194. package/packages/tallow-tui/node_modules/marked/lib/marked.d.cts +0 -728
  195. package/packages/tallow-tui/node_modules/marked/marked.min.js +0 -69
@@ -1,11 +1,71 @@
1
- import { getEditorKeybindings } from "../keybindings.js";
2
- import { matchesKey } from "../keys.js";
1
+ import { getKeybindings } from "../keybindings.js";
2
+ import { decodeKittyPrintable, matchesKey } from "../keys.js";
3
3
  import { KillRing } from "../kill-ring.js";
4
4
  import { CURSOR_MARKER } from "../tui.js";
5
5
  import { UndoStack } from "../undo-stack.js";
6
- import { getSegmenter, isPunctuationChar, isWhitespaceChar, visibleWidth } from "../utils.js";
6
+ import { getSegmenter, isPunctuationChar, isWhitespaceChar, truncateToWidth, visibleWidth, } from "../utils.js";
7
7
  import { SelectList } from "./select-list.js";
8
- const segmenter = getSegmenter();
8
+ const baseSegmenter = getSegmenter();
9
+ /** Regex matching paste markers like `[paste #1 +123 lines]` or `[paste #2 1234 chars]`. */
10
+ const PASTE_MARKER_REGEX = /\[paste #(\d+)( (\+\d+ lines|\d+ chars))?\]/g;
11
+ /** Non-global version for single-segment testing. */
12
+ const PASTE_MARKER_SINGLE = /^\[paste #(\d+)( (\+\d+ lines|\d+ chars))?\]$/;
13
+ /** Check if a segment is a paste marker (i.e. was merged by segmentWithMarkers). */
14
+ function isPasteMarker(segment) {
15
+ return segment.length >= 10 && PASTE_MARKER_SINGLE.test(segment);
16
+ }
17
+ /**
18
+ * A segmenter that wraps Intl.Segmenter and merges graphemes that fall
19
+ * within paste markers into single atomic segments. This makes cursor
20
+ * movement, deletion, word-wrap, etc. treat paste markers as single units.
21
+ *
22
+ * Only markers whose numeric ID exists in `validIds` are merged.
23
+ */
24
+ function segmentWithMarkers(text, validIds) {
25
+ // Fast path: no paste markers in the text or no valid IDs.
26
+ if (validIds.size === 0 || !text.includes("[paste #")) {
27
+ return baseSegmenter.segment(text);
28
+ }
29
+ // Find all marker spans with valid IDs.
30
+ const markers = [];
31
+ for (const m of text.matchAll(PASTE_MARKER_REGEX)) {
32
+ const id = Number.parseInt(m[1], 10);
33
+ if (!validIds.has(id))
34
+ continue;
35
+ markers.push({ start: m.index, end: m.index + m[0].length });
36
+ }
37
+ if (markers.length === 0) {
38
+ return baseSegmenter.segment(text);
39
+ }
40
+ // Build merged segment list.
41
+ const baseSegments = baseSegmenter.segment(text);
42
+ const result = [];
43
+ let markerIdx = 0;
44
+ for (const seg of baseSegments) {
45
+ // Skip past markers that are entirely before this segment.
46
+ while (markerIdx < markers.length && markers[markerIdx].end <= seg.index) {
47
+ markerIdx++;
48
+ }
49
+ const marker = markerIdx < markers.length ? markers[markerIdx] : null;
50
+ if (marker && seg.index >= marker.start && seg.index < marker.end) {
51
+ // This segment falls inside a marker.
52
+ // If this is the first segment of the marker, emit a merged segment.
53
+ if (seg.index === marker.start) {
54
+ const markerText = text.slice(marker.start, marker.end);
55
+ result.push({
56
+ segment: markerText,
57
+ index: marker.start,
58
+ input: text,
59
+ });
60
+ }
61
+ // Otherwise skip (already merged into the first segment).
62
+ }
63
+ else {
64
+ result.push(seg);
65
+ }
66
+ }
67
+ return result;
68
+ }
9
69
  /**
10
70
  * Split a line into word-wrapped chunks.
11
71
  * Wraps at word boundaries when possible, falling back to character-level
@@ -13,9 +73,11 @@ const segmenter = getSegmenter();
13
73
  *
14
74
  * @param line - The text line to wrap
15
75
  * @param maxWidth - Maximum visible width per chunk
76
+ * @param preSegmented - Optional pre-segmented graphemes (e.g. with paste-marker awareness).
77
+ * When omitted the default Intl.Segmenter is used.
16
78
  * @returns Array of chunks with text and position information
17
79
  */
18
- export function wordWrapLine(line, maxWidth) {
80
+ export function wordWrapLine(line, maxWidth, preSegmented) {
19
81
  if (!line || maxWidth <= 0) {
20
82
  return [{ text: "", startIndex: 0, endIndex: 0 }];
21
83
  }
@@ -24,7 +86,7 @@ export function wordWrapLine(line, maxWidth) {
24
86
  return [{ text: line, startIndex: 0, endIndex: line.length }];
25
87
  }
26
88
  const chunks = [];
27
- const segments = [...segmenter.segment(line)];
89
+ const segments = preSegmented ?? [...baseSegmenter.segment(line)];
28
90
  let currentWidth = 0;
29
91
  let chunkStart = 0;
30
92
  // Wrap opportunity: the position after the last whitespace before a non-whitespace
@@ -36,11 +98,12 @@ export function wordWrapLine(line, maxWidth) {
36
98
  const grapheme = seg.segment;
37
99
  const gWidth = visibleWidth(grapheme);
38
100
  const charIndex = seg.index;
39
- const isWs = isWhitespaceChar(grapheme);
101
+ const isWs = !isPasteMarker(grapheme) && isWhitespaceChar(grapheme);
40
102
  // Overflow check before advancing.
41
103
  if (currentWidth + gWidth > maxWidth) {
42
- if (wrapOppIndex >= 0) {
43
- // Backtrack to last wrap opportunity.
104
+ if (wrapOppIndex >= 0 && currentWidth - wrapOppWidth + gWidth <= maxWidth) {
105
+ // Backtrack to last wrap opportunity (the remaining content
106
+ // plus the current grapheme still fits within maxWidth).
44
107
  chunks.push({
45
108
  text: line.slice(chunkStart, wrapOppIndex),
46
109
  startIndex: chunkStart,
@@ -50,7 +113,11 @@ export function wordWrapLine(line, maxWidth) {
50
113
  currentWidth -= wrapOppWidth;
51
114
  }
52
115
  else if (chunkStart < charIndex) {
53
- // No wrap opportunity: force-break at current position.
116
+ // No viable wrap opportunity: force-break at current position.
117
+ // This also handles the case where backtracking to a word
118
+ // boundary wouldn't help because the remaining content plus
119
+ // the current grapheme (e.g. a wide character) still exceeds
120
+ // maxWidth.
54
121
  chunks.push({
55
122
  text: line.slice(chunkStart, charIndex),
56
123
  startIndex: chunkStart,
@@ -61,13 +128,33 @@ export function wordWrapLine(line, maxWidth) {
61
128
  }
62
129
  wrapOppIndex = -1;
63
130
  }
131
+ if (gWidth > maxWidth) {
132
+ // Single atomic segment wider than maxWidth (e.g. paste marker
133
+ // in a narrow terminal). Re-wrap it at grapheme granularity.
134
+ // The segment remains logically atomic for cursor
135
+ // movement / editing — the split is purely visual for word-wrap layout.
136
+ const subChunks = wordWrapLine(grapheme, maxWidth);
137
+ for (let j = 0; j < subChunks.length - 1; j++) {
138
+ const sc = subChunks[j];
139
+ chunks.push({
140
+ text: sc.text,
141
+ startIndex: charIndex + sc.startIndex,
142
+ endIndex: charIndex + sc.endIndex,
143
+ });
144
+ }
145
+ const last = subChunks[subChunks.length - 1];
146
+ chunkStart = charIndex + last.startIndex;
147
+ currentWidth = visibleWidth(last.text);
148
+ wrapOppIndex = -1;
149
+ continue;
150
+ }
64
151
  // Advance.
65
152
  currentWidth += gWidth;
66
153
  // Record wrap opportunity: whitespace followed by non-whitespace.
67
154
  // Multiple spaces join (no break between them); the break point is
68
155
  // after the last space before the next word.
69
156
  const next = segments[i + 1];
70
- if (isWs && next && !isWhitespaceChar(next.segment)) {
157
+ if (isWs && next && (isPasteMarker(next.segment) || !isWhitespaceChar(next.segment))) {
71
158
  wrapOppIndex = next.index;
72
159
  wrapOppWidth = currentWidth;
73
160
  }
@@ -76,42 +163,11 @@ export function wordWrapLine(line, maxWidth) {
76
163
  chunks.push({ text: line.slice(chunkStart), startIndex: chunkStart, endIndex: line.length });
77
164
  return chunks;
78
165
  }
79
- // Kitty CSI-u sequences for printable keys, including optional shifted/base codepoints.
80
- const KITTY_CSI_U_REGEX = /^\x1b\[(\d+)(?::(\d*))?(?::(\d+))?(?:;(\d+))?(?::(\d+))?u$/;
81
- const KITTY_MOD_SHIFT = 1;
82
- const KITTY_MOD_ALT = 2;
83
- const KITTY_MOD_CTRL = 4;
84
- // Decode a printable CSI-u sequence, preferring the shifted key when present.
85
- function decodeKittyPrintable(data) {
86
- const match = data.match(KITTY_CSI_U_REGEX);
87
- if (!match)
88
- return undefined;
89
- // CSI-u groups: <codepoint>[:<shifted>[:<base>]];<mod>u
90
- const codepoint = Number.parseInt(match[1] ?? "", 10);
91
- if (!Number.isFinite(codepoint))
92
- return undefined;
93
- const shiftedKey = match[2] && match[2].length > 0 ? Number.parseInt(match[2], 10) : undefined;
94
- const modValue = match[4] ? Number.parseInt(match[4], 10) : 1;
95
- // Modifiers are 1-indexed in CSI-u; normalize to our bitmask.
96
- const modifier = Number.isFinite(modValue) ? modValue - 1 : 0;
97
- // Ignore CSI-u sequences used for Alt/Ctrl shortcuts.
98
- if (modifier & (KITTY_MOD_ALT | KITTY_MOD_CTRL))
99
- return undefined;
100
- // Prefer the shifted keycode when Shift is held.
101
- let effectiveCodepoint = codepoint;
102
- if (modifier & KITTY_MOD_SHIFT && typeof shiftedKey === "number") {
103
- effectiveCodepoint = shiftedKey;
104
- }
105
- // Drop control characters or invalid codepoints.
106
- if (!Number.isFinite(effectiveCodepoint) || effectiveCodepoint < 32)
107
- return undefined;
108
- try {
109
- return String.fromCodePoint(effectiveCodepoint);
110
- }
111
- catch {
112
- return undefined;
113
- }
114
- }
166
+ const SLASH_COMMAND_SELECT_LIST_LAYOUT = {
167
+ minPrimaryColumnWidth: 12,
168
+ maxPrimaryColumnWidth: 32,
169
+ };
170
+ const ATTACHMENT_AUTOCOMPLETE_DEBOUNCE_MS = 20;
115
171
  export class Editor {
116
172
  state = {
117
173
  lines: [""],
@@ -135,6 +191,11 @@ export class Editor {
135
191
  autocompleteState = null;
136
192
  autocompletePrefix = "";
137
193
  autocompleteMaxVisible = 5;
194
+ autocompleteAbort;
195
+ autocompleteDebounceTimer;
196
+ autocompleteRequestTask = Promise.resolve();
197
+ autocompleteStartToken = 0;
198
+ autocompleteRequestId = 0;
138
199
  // Paste tracking for large pastes
139
200
  pastes = new Map();
140
201
  pasteCounter = 0;
@@ -151,12 +212,14 @@ export class Editor {
151
212
  jumpMode = null;
152
213
  // Preferred visual column for vertical cursor movement (sticky column)
153
214
  preferredVisualCol = null;
215
+ // When the cursor is snapped to the start of an atomic segment, e.g. a
216
+ // paste marker, cursorCol no longer reflects where the cursor would have
217
+ // landed. This field stores the pre-snap cursorCol so that the next
218
+ // vertical move can resolve it to a visual column on whatever VL it belongs
219
+ // to.
220
+ snappedFromCursorCol = null;
154
221
  // Undo support
155
222
  undoStack = new UndoStack();
156
- // Ghost text (inline suggestion shown as dim text after cursor)
157
- ghostTextValue = null;
158
- /** Additional change listeners that won't be overwritten by framework wiring. */
159
- changeListeners = [];
160
223
  onSubmit;
161
224
  onChange;
162
225
  disableSubmit = false;
@@ -171,6 +234,14 @@ export class Editor {
171
234
  ? Math.max(3, Math.min(20, Math.floor(maxVisible)))
172
235
  : 5;
173
236
  }
237
+ /** Set of currently valid paste IDs, for marker-aware segmentation. */
238
+ validPasteIds() {
239
+ return new Set(this.pastes.keys());
240
+ }
241
+ /** Segment text with paste-marker awareness, only merging markers with valid IDs. */
242
+ segment(text) {
243
+ return segmentWithMarkers(text, this.validPasteIds());
244
+ }
174
245
  getPaddingX() {
175
246
  return this.paddingX;
176
247
  }
@@ -194,6 +265,7 @@ export class Editor {
194
265
  }
195
266
  }
196
267
  setAutocompleteProvider(provider) {
268
+ this.cancelAutocomplete();
197
269
  this.autocompleteProvider = provider;
198
270
  }
199
271
  /**
@@ -248,13 +320,15 @@ export class Editor {
248
320
  }
249
321
  /** Internal setText that doesn't reset history state - used by navigateHistory */
250
322
  setTextInternal(text) {
251
- const lines = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n");
323
+ const lines = text.split("\n");
252
324
  this.state.lines = lines.length === 0 ? [""] : lines;
253
325
  this.state.cursorLine = this.state.lines.length - 1;
254
326
  this.setCursorCol(this.state.lines[this.state.cursorLine]?.length || 0);
255
327
  // Reset scroll - render() will adjust to show cursor
256
328
  this.scrollOffset = 0;
257
- this.notifyChange();
329
+ if (this.onChange) {
330
+ this.onChange(this.getText());
331
+ }
258
332
  }
259
333
  invalidate() {
260
334
  // No cached state to invalidate currently
@@ -297,7 +371,12 @@ export class Editor {
297
371
  if (this.scrollOffset > 0) {
298
372
  const indicator = `─── ↑ ${this.scrollOffset} more `;
299
373
  const remaining = width - visibleWidth(indicator);
300
- result.push(this.borderColor(indicator + "─".repeat(Math.max(0, remaining))));
374
+ if (remaining >= 0) {
375
+ result.push(this.borderColor(indicator + "─".repeat(remaining)));
376
+ }
377
+ else {
378
+ result.push(this.borderColor(truncateToWidth(indicator, width)));
379
+ }
301
380
  }
302
381
  else {
303
382
  result.push(horizontal.repeat(width));
@@ -318,7 +397,7 @@ export class Editor {
318
397
  if (after.length > 0) {
319
398
  // Cursor is on a character (grapheme) - replace it with highlighted version
320
399
  // Get the first grapheme from 'after'
321
- const afterGraphemes = [...segmenter.segment(after)];
400
+ const afterGraphemes = [...this.segment(after)];
322
401
  const firstGrapheme = afterGraphemes[0]?.segment || "";
323
402
  const restAfter = after.slice(firstGrapheme.length);
324
403
  const cursor = `\x1b[7m${firstGrapheme}\x1b[0m`;
@@ -336,21 +415,6 @@ export class Editor {
336
415
  }
337
416
  }
338
417
  }
339
- // Ghost text: show dim suggestion after cursor on the cursor line (end of input only)
340
- if (layoutLine.hasCursor &&
341
- this.ghostTextValue &&
342
- layoutLine.cursorPos !== undefined &&
343
- layoutLine.cursorPos >= layoutLine.text.length) {
344
- // Truncate ghost text to fit remaining content width
345
- const available = contentWidth - lineVisibleWidth;
346
- if (available > 0) {
347
- const truncated = this.ghostTextValue.length > available
348
- ? this.ghostTextValue.slice(0, available)
349
- : this.ghostTextValue;
350
- displayText += `\x1b[38;5;242m${truncated}\x1b[0m`;
351
- lineVisibleWidth += truncated.length;
352
- }
353
- }
354
418
  // Calculate padding based on actual visible width
355
419
  const padding = " ".repeat(Math.max(0, contentWidth - lineVisibleWidth));
356
420
  const lineRightPadding = cursorInPadding ? rightPadding.slice(1) : rightPadding;
@@ -379,11 +443,12 @@ export class Editor {
379
443
  return result;
380
444
  }
381
445
  handleInput(data) {
382
- const kb = getEditorKeybindings();
446
+ const kb = getKeybindings();
383
447
  // Handle character jump mode (awaiting next character to jump to)
384
448
  if (this.jumpMode !== null) {
385
449
  // Cancel if the hotkey is pressed again
386
- if (kb.matches(data, "jumpForward") || kb.matches(data, "jumpBackward")) {
450
+ if (kb.matches(data, "tui.editor.jumpForward") ||
451
+ kb.matches(data, "tui.editor.jumpBackward")) {
387
452
  this.jumpMode = null;
388
453
  return;
389
454
  }
@@ -422,25 +487,25 @@ export class Editor {
422
487
  return;
423
488
  }
424
489
  // Ctrl+C - let parent handle (exit/clear)
425
- if (kb.matches(data, "copy")) {
490
+ if (kb.matches(data, "tui.input.copy")) {
426
491
  return;
427
492
  }
428
493
  // Undo
429
- if (kb.matches(data, "undo")) {
494
+ if (kb.matches(data, "tui.editor.undo")) {
430
495
  this.undo();
431
496
  return;
432
497
  }
433
498
  // Handle autocomplete mode
434
499
  if (this.autocompleteState && this.autocompleteList) {
435
- if (kb.matches(data, "selectCancel")) {
500
+ if (kb.matches(data, "tui.select.cancel")) {
436
501
  this.cancelAutocomplete();
437
502
  return;
438
503
  }
439
- if (kb.matches(data, "selectUp") || kb.matches(data, "selectDown")) {
504
+ if (kb.matches(data, "tui.select.up") || kb.matches(data, "tui.select.down")) {
440
505
  this.autocompleteList.handleInput(data);
441
506
  return;
442
507
  }
443
- if (kb.matches(data, "tab")) {
508
+ if (kb.matches(data, "tui.input.tab")) {
444
509
  const selected = this.autocompleteList.getSelectedItem();
445
510
  if (selected && this.autocompleteProvider) {
446
511
  this.pushUndoSnapshot();
@@ -450,11 +515,12 @@ export class Editor {
450
515
  this.state.cursorLine = result.cursorLine;
451
516
  this.setCursorCol(result.cursorCol);
452
517
  this.cancelAutocomplete();
453
- this.notifyChange();
518
+ if (this.onChange)
519
+ this.onChange(this.getText());
454
520
  }
455
521
  return;
456
522
  }
457
- if (kb.matches(data, "selectConfirm")) {
523
+ if (kb.matches(data, "tui.select.confirm")) {
458
524
  const selected = this.autocompleteList.getSelectedItem();
459
525
  if (selected && this.autocompleteProvider) {
460
526
  this.pushUndoSnapshot();
@@ -469,80 +535,71 @@ export class Editor {
469
535
  }
470
536
  else {
471
537
  this.cancelAutocomplete();
472
- this.notifyChange();
538
+ if (this.onChange)
539
+ this.onChange(this.getText());
473
540
  return;
474
541
  }
475
542
  }
476
543
  }
477
544
  }
478
- // Escape - dismiss ghost text when no autocomplete is active
479
- if (kb.matches(data, "selectCancel") && !this.autocompleteState && this.ghostTextValue) {
480
- this.ghostTextValue = null;
481
- this.tui.requestRender();
482
- return;
483
- }
484
- // Tab - accept ghost text or trigger completion
485
- if (kb.matches(data, "tab") && !this.autocompleteState) {
486
- if (this.ghostTextValue) {
487
- this.acceptGhostText();
488
- return;
489
- }
545
+ // Tab - trigger completion
546
+ if (kb.matches(data, "tui.input.tab") && !this.autocompleteState) {
490
547
  this.handleTabCompletion();
491
548
  return;
492
549
  }
493
550
  // Deletion actions
494
- if (kb.matches(data, "deleteToLineEnd")) {
551
+ if (kb.matches(data, "tui.editor.deleteToLineEnd")) {
495
552
  this.deleteToEndOfLine();
496
553
  return;
497
554
  }
498
- if (kb.matches(data, "deleteToLineStart")) {
555
+ if (kb.matches(data, "tui.editor.deleteToLineStart")) {
499
556
  this.deleteToStartOfLine();
500
557
  return;
501
558
  }
502
- if (kb.matches(data, "deleteWordBackward")) {
559
+ if (kb.matches(data, "tui.editor.deleteWordBackward")) {
503
560
  this.deleteWordBackwards();
504
561
  return;
505
562
  }
506
- if (kb.matches(data, "deleteWordForward")) {
563
+ if (kb.matches(data, "tui.editor.deleteWordForward")) {
507
564
  this.deleteWordForward();
508
565
  return;
509
566
  }
510
- if (kb.matches(data, "deleteCharBackward") || matchesKey(data, "shift+backspace")) {
567
+ if (kb.matches(data, "tui.editor.deleteCharBackward") || matchesKey(data, "shift+backspace")) {
511
568
  this.handleBackspace();
512
569
  return;
513
570
  }
514
- if (kb.matches(data, "deleteCharForward") || matchesKey(data, "shift+delete")) {
571
+ if (kb.matches(data, "tui.editor.deleteCharForward") || matchesKey(data, "shift+delete")) {
515
572
  this.handleForwardDelete();
516
573
  return;
517
574
  }
518
575
  // Kill ring actions
519
- if (kb.matches(data, "yank")) {
576
+ if (kb.matches(data, "tui.editor.yank")) {
520
577
  this.yank();
521
578
  return;
522
579
  }
523
- if (kb.matches(data, "yankPop")) {
580
+ if (kb.matches(data, "tui.editor.yankPop")) {
524
581
  this.yankPop();
525
582
  return;
526
583
  }
527
584
  // Cursor movement actions
528
- if (kb.matches(data, "cursorLineStart")) {
585
+ if (kb.matches(data, "tui.editor.cursorLineStart")) {
529
586
  this.moveToLineStart();
530
587
  return;
531
588
  }
532
- if (kb.matches(data, "cursorLineEnd")) {
589
+ if (kb.matches(data, "tui.editor.cursorLineEnd")) {
533
590
  this.moveToLineEnd();
534
591
  return;
535
592
  }
536
- if (kb.matches(data, "cursorWordLeft")) {
593
+ if (kb.matches(data, "tui.editor.cursorWordLeft")) {
537
594
  this.moveWordBackwards();
538
595
  return;
539
596
  }
540
- if (kb.matches(data, "cursorWordRight")) {
597
+ if (kb.matches(data, "tui.editor.cursorWordRight")) {
541
598
  this.moveWordForwards();
542
599
  return;
543
600
  }
544
601
  // New line
545
- if (kb.matches(data, "newLine") ||
602
+ if (kb.matches(data, "tui.input.newLine") ||
546
603
  (data.charCodeAt(0) === 10 && data.length > 1) ||
547
604
  data === "\x1b\r" ||
548
605
  data === "\x1b[13;2~" ||
@@ -557,15 +614,9 @@ export class Editor {
557
614
  return;
558
615
  }
559
616
  // Submit (Enter)
560
- if (kb.matches(data, "submit")) {
617
+ if (kb.matches(data, "tui.input.submit")) {
561
618
  if (this.disableSubmit)
562
619
  return;
563
- // Accept ghost text on Enter when input is empty — "just hit Enter" experience
564
- if (this.isEditorEmpty() && this.ghostTextValue) {
565
- this.acceptGhostText();
566
- this.submitValue();
567
- return;
568
- }
569
620
  // Workaround for terminals without Shift+Enter support:
570
621
  // If char before cursor is \, delete it and insert newline instead of submitting.
571
622
  const currentLine = this.state.lines[this.state.cursorLine] || "";
@@ -578,7 +629,7 @@ export class Editor {
578
629
  return;
579
630
  }
580
631
  // Arrow key navigation (with history support)
581
- if (kb.matches(data, "cursorUp")) {
632
+ if (kb.matches(data, "tui.editor.cursorUp")) {
582
633
  if (this.isEditorEmpty()) {
583
634
  this.navigateHistory(-1);
584
635
  }
@@ -594,7 +645,7 @@ export class Editor {
594
645
  }
595
646
  return;
596
647
  }
597
- if (kb.matches(data, "cursorDown")) {
648
+ if (kb.matches(data, "tui.editor.cursorDown")) {
598
649
  if (this.historyIndex > -1 && this.isOnLastVisualLine()) {
599
650
  this.navigateHistory(1);
600
651
  }
@@ -607,29 +658,29 @@ export class Editor {
607
658
  }
608
659
  return;
609
660
  }
610
- if (kb.matches(data, "cursorRight")) {
661
+ if (kb.matches(data, "tui.editor.cursorRight")) {
611
662
  this.moveCursor(0, 1);
612
663
  return;
613
664
  }
614
- if (kb.matches(data, "cursorLeft")) {
665
+ if (kb.matches(data, "tui.editor.cursorLeft")) {
615
666
  this.moveCursor(0, -1);
616
667
  return;
617
668
  }
618
669
  // Page up/down - scroll by page and move cursor
619
- if (kb.matches(data, "pageUp")) {
670
+ if (kb.matches(data, "tui.editor.pageUp")) {
620
671
  this.pageScroll(-1);
621
672
  return;
622
673
  }
623
- if (kb.matches(data, "pageDown")) {
674
+ if (kb.matches(data, "tui.editor.pageDown")) {
624
675
  this.pageScroll(1);
625
676
  return;
626
677
  }
627
678
  // Character jump mode triggers
628
- if (kb.matches(data, "jumpForward")) {
679
+ if (kb.matches(data, "tui.editor.jumpForward")) {
629
680
  this.jumpMode = "forward";
630
681
  return;
631
682
  }
632
- if (kb.matches(data, "jumpBackward")) {
683
+ if (kb.matches(data, "tui.editor.jumpBackward")) {
633
684
  this.jumpMode = "backward";
634
685
  return;
635
686
  }
@@ -683,7 +734,7 @@ export class Editor {
683
734
  }
684
735
  else {
685
736
  // Line needs wrapping - use word-aware wrapping
686
- const chunks = wordWrapLine(line, contentWidth);
737
+ const chunks = wordWrapLine(line, contentWidth, [...this.segment(line)]);
687
738
  for (let chunkIndex = 0; chunkIndex < chunks.length; chunkIndex++) {
688
739
  const chunk = chunks[chunkIndex];
689
740
  if (!chunk)
@@ -735,17 +786,20 @@ export class Editor {
735
786
  getText() {
736
787
  return this.state.lines.join("\n");
737
788
  }
789
+ expandPasteMarkers(text) {
790
+ let result = text;
791
+ for (const [pasteId, pasteContent] of this.pastes) {
792
+ const markerRegex = new RegExp(`\\[paste #${pasteId}( (\\+\\d+ lines|\\d+ chars))?\\]`, "g");
793
+ result = result.replace(markerRegex, () => pasteContent);
794
+ }
795
+ return result;
796
+ }
738
797
  /**
739
798
  * Get text with paste markers expanded to their actual content.
740
799
  * Use this when you need the full content (e.g., for external editor).
741
800
  */
742
801
  getExpandedText() {
743
- let result = this.state.lines.join("\n");
744
- for (const [pasteId, pasteContent] of this.pastes) {
745
- const markerRegex = new RegExp(`\\[paste #${pasteId}( (\\+\\d+ lines|\\d+ chars))?\\]`, "g");
746
- result = result.replace(markerRegex, pasteContent);
747
- }
748
- return result;
802
+ return this.expandPasteMarkers(this.state.lines.join("\n"));
749
803
  }
750
804
  getLines() {
751
805
  return [...this.state.lines];
@@ -754,69 +808,15 @@ export class Editor {
754
808
  return { line: this.state.cursorLine, col: this.state.cursorCol };
755
809
  }
756
810
  setText(text) {
811
+ this.cancelAutocomplete();
757
812
  this.lastAction = null;
758
813
  this.historyIndex = -1; // Exit history browsing mode
814
+ const normalized = this.normalizeText(text);
759
815
  // Push undo snapshot if content differs (makes programmatic changes undoable)
760
- if (this.getText() !== text) {
816
+ if (this.getText() !== normalized) {
761
817
  this.pushUndoSnapshot();
762
818
  }
763
- this.setTextInternal(text);
764
- }
765
- /**
766
- * Set ghost text (inline suggestion shown as dim text after the cursor).
767
- * Pass null to clear. Ghost text is purely visual — not part of the buffer.
768
- *
769
- * @param text - Suggestion to display, or null to clear
770
- */
771
- setGhostText(text) {
772
- if (this.ghostTextValue !== text) {
773
- this.ghostTextValue = text;
774
- this.tui.requestRender();
775
- }
776
- }
777
- /**
778
- * Get the current ghost text, or null if none.
779
- *
780
- * @returns Current ghost text string, or null
781
- */
782
- getGhostText() {
783
- return this.ghostTextValue;
784
- }
785
- /**
786
- * Register a change listener that fires alongside onChange.
787
- * Unlike onChange, listeners aren't overwritten by framework wiring.
788
- *
789
- * @param fn - Callback receiving the new text content
790
- */
791
- addChangeListener(fn) {
792
- this.changeListeners.push(fn);
793
- }
794
- /**
795
- * Notify onChange and all registered change listeners.
796
- * Centralises all text-change notifications.
797
- */
798
- notifyChange() {
799
- const text = this.getText();
800
- this.onChange?.(text);
801
- for (const fn of this.changeListeners) {
802
- fn(text);
803
- }
804
- }
805
- /**
806
- * Accept ghost text into the editor buffer at the cursor position.
807
- * Clears ghost text and triggers onChange.
808
- *
809
- * @returns true if ghost text was accepted, false if none was showing
810
- */
811
- acceptGhostText() {
812
- if (!this.ghostTextValue)
813
- return false;
814
- const text = this.ghostTextValue;
815
- this.ghostTextValue = null;
816
- this.pushUndoSnapshot();
817
- this.insertTextAtCursorInternal(text);
818
- this.notifyChange();
819
- return true;
819
+ this.setTextInternal(normalized);
820
820
  }
821
821
  /**
822
822
  * Insert text at the current cursor position.
@@ -826,11 +826,20 @@ export class Editor {
826
826
  insertTextAtCursor(text) {
827
827
  if (!text)
828
828
  return;
829
+ this.cancelAutocomplete();
829
830
  this.pushUndoSnapshot();
830
831
  this.lastAction = null;
831
832
  this.historyIndex = -1;
832
833
  this.insertTextAtCursorInternal(text);
833
834
  }
835
+ /**
836
+ * Normalize text for editor storage:
837
+ * - Normalize line endings (\r\n and \r -> \n)
838
+ * - Expand tabs to 4 spaces
839
+ */
840
+ normalizeText(text) {
841
+ return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n").replace(/\t/g, " ");
842
+ }
834
843
  /**
835
844
  * Internal text insertion at cursor. Handles single and multi-line text.
836
845
  * Does not push undo snapshots or trigger autocomplete - caller is responsible.
@@ -839,8 +848,8 @@ export class Editor {
839
848
  insertTextAtCursorInternal(text) {
840
849
  if (!text)
841
850
  return;
842
- // Normalize line endings
843
- const normalized = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
851
+ // Normalize line endings and tabs
852
+ const normalized = this.normalizeText(text);
844
853
  const insertedLines = normalized.split("\n");
845
854
  const currentLine = this.state.lines[this.state.cursorLine] || "";
846
855
  const beforeCursor = currentLine.slice(0, this.state.cursorCol);
@@ -867,13 +876,13 @@ export class Editor {
867
876
  this.state.cursorLine += insertedLines.length - 1;
868
877
  this.setCursorCol((insertedLines[insertedLines.length - 1] || "").length);
869
878
  }
870
- this.notifyChange();
879
+ if (this.onChange) {
880
+ this.onChange(this.getText());
881
+ }
871
882
  }
872
883
  // All the editor methods from before...
873
884
  insertCharacter(char, skipUndoCoalescing) {
874
885
  this.historyIndex = -1; // Exit history browsing mode
875
- // Dismiss ghost text on any character input
876
- this.ghostTextValue = null;
877
886
  // Undo coalescing (fish-style):
878
887
  // - Consecutive word chars coalesce into one undo unit
879
888
  // - Space captures state before itself (so undo removes space+following word together)
@@ -890,7 +899,9 @@ export class Editor {
890
899
  const after = line.slice(this.state.cursorCol);
891
900
  this.state.lines[this.state.cursorLine] = before + char + after;
892
901
  this.setCursorCol(this.state.cursorCol + char.length);
893
- this.notifyChange();
902
+ if (this.onChange) {
903
+ this.onChange(this.getText());
904
+ }
894
905
  // Check if we should trigger or update autocomplete
895
906
  if (!this.autocompleteState) {
896
907
  // Auto-trigger for "/" at the start of a line (slash commands)
@@ -926,15 +937,14 @@ export class Editor {
926
937
  }
927
938
  }
928
939
  handlePaste(pastedText) {
940
+ this.cancelAutocomplete();
929
941
  this.historyIndex = -1; // Exit history browsing mode
930
942
  this.lastAction = null;
931
943
  this.pushUndoSnapshot();
932
- // Clean the pasted text
933
- const cleanText = pastedText.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
934
- // Convert tabs to spaces (4 spaces per tab)
935
- const tabExpandedText = cleanText.replace(/\t/g, " ");
944
+ // Clean the pasted text: normalize line endings, expand tabs
945
+ const cleanText = this.normalizeText(pastedText);
936
946
  // Filter out non-printable characters except newlines
937
- let filteredText = tabExpandedText
947
+ let filteredText = cleanText
938
948
  .split("")
939
949
  .filter((char) => char === "\n" || char.charCodeAt(0) >= 32)
940
950
  .join("");
@@ -964,16 +974,15 @@ export class Editor {
964
974
  return;
965
975
  }
966
976
  if (pastedLines.length === 1) {
967
- // Single line - insert character by character to trigger autocomplete
968
- for (const char of filteredText) {
969
- this.insertCharacter(char, true);
970
- }
977
+ // Single line - insert atomically (do not trigger autocomplete during paste)
978
+ this.insertTextAtCursorInternal(filteredText);
971
979
  return;
972
980
  }
973
981
  // Multi-line paste - use direct state manipulation
974
982
  this.insertTextAtCursorInternal(filteredText);
975
983
  }
976
984
  addNewLine() {
985
+ this.cancelAutocomplete();
977
986
  this.historyIndex = -1; // Exit history browsing mode
978
987
  this.lastAction = null;
979
988
  this.pushUndoSnapshot();
@@ -986,14 +995,16 @@ export class Editor {
986
995
  // Move cursor to start of new line
987
996
  this.state.cursorLine++;
988
997
  this.setCursorCol(0);
989
- this.notifyChange();
998
+ if (this.onChange) {
999
+ this.onChange(this.getText());
1000
+ }
990
1001
  }
991
1002
  shouldSubmitOnBackslashEnter(data, kb) {
992
1003
  if (this.disableSubmit)
993
1004
  return false;
994
1005
  if (!matchesKey(data, "enter"))
995
1006
  return false;
996
- const submitKeys = kb.getKeys("submit");
1007
+ const submitKeys = kb.getKeys("tui.input.submit");
997
1008
  const hasShiftEnter = submitKeys.includes("shift+enter") || submitKeys.includes("shift+return");
998
1009
  if (!hasShiftEnter)
999
1010
  return false;
@@ -1001,11 +1012,8 @@ export class Editor {
1001
1012
  return this.state.cursorCol > 0 && currentLine[this.state.cursorCol - 1] === "\\";
1002
1013
  }
1003
1014
  submitValue() {
1004
- let result = this.state.lines.join("\n").trim();
1005
- for (const [pasteId, pasteContent] of this.pastes) {
1006
- const markerRegex = new RegExp(`\\[paste #${pasteId}( (\\+\\d+ lines|\\d+ chars))?\\]`, "g");
1007
- result = result.replace(markerRegex, pasteContent);
1008
- }
1015
+ this.cancelAutocomplete();
1016
+ const result = this.expandPasteMarkers(this.state.lines.join("\n")).trim();
1009
1017
  this.state = { lines: [""], cursorLine: 0, cursorCol: 0 };
1010
1018
  this.pastes.clear();
1011
1019
  this.pasteCounter = 0;
@@ -1013,21 +1021,21 @@ export class Editor {
1013
1021
  this.scrollOffset = 0;
1014
1022
  this.undoStack.clear();
1015
1023
  this.lastAction = null;
1016
- this.notifyChange();
1024
+ if (this.onChange)
1025
+ this.onChange("");
1017
1026
  if (this.onSubmit)
1018
1027
  this.onSubmit(result);
1019
1028
  }
1020
1029
  handleBackspace() {
1021
1030
  this.historyIndex = -1; // Exit history browsing mode
1022
1031
  this.lastAction = null;
1023
- this.ghostTextValue = null;
1024
1032
  if (this.state.cursorCol > 0) {
1025
1033
  this.pushUndoSnapshot();
1026
1034
  // Delete grapheme before cursor (handles emojis, combining characters, etc.)
1027
1035
  const line = this.state.lines[this.state.cursorLine] || "";
1028
1036
  const beforeCursor = line.slice(0, this.state.cursorCol);
1029
1037
  // Find the last grapheme in the text before cursor
1030
- const graphemes = [...segmenter.segment(beforeCursor)];
1038
+ const graphemes = [...this.segment(beforeCursor)];
1031
1039
  const lastGrapheme = graphemes[graphemes.length - 1];
1032
1040
  const graphemeLength = lastGrapheme ? lastGrapheme.segment.length : 1;
1033
1041
  const before = line.slice(0, this.state.cursorCol - graphemeLength);
@@ -1045,7 +1053,9 @@ export class Editor {
1045
1053
  this.state.cursorLine--;
1046
1054
  this.setCursorCol(previousLine.length);
1047
1055
  }
1048
- this.notifyChange();
1056
+ if (this.onChange) {
1057
+ this.onChange(this.getText());
1058
+ }
1049
1059
  // Update or re-trigger autocomplete after backspace
1050
1060
  if (this.autocompleteState) {
1051
1061
  this.updateAutocomplete();
@@ -1071,6 +1081,7 @@ export class Editor {
1071
1081
  setCursorCol(col) {
1072
1082
  this.state.cursorCol = col;
1073
1083
  this.preferredVisualCol = null;
1084
+ this.snappedFromCursorCol = null;
1074
1085
  }
1075
1086
  /**
1076
1087
  * Move cursor to a target visual line, applying sticky column logic.
@@ -1079,26 +1090,74 @@ export class Editor {
1079
1090
  moveToVisualLine(visualLines, currentVisualLine, targetVisualLine) {
1080
1091
  const currentVL = visualLines[currentVisualLine];
1081
1092
  const targetVL = visualLines[targetVisualLine];
1082
- if (currentVL && targetVL) {
1083
- const currentVisualCol = this.state.cursorCol - currentVL.startCol;
1084
- // For non-last segments, clamp to length-1 to stay within the segment
1085
- const isLastSourceSegment = currentVisualLine === visualLines.length - 1 ||
1086
- visualLines[currentVisualLine + 1]?.logicalLine !== currentVL.logicalLine;
1087
- const sourceMaxVisualCol = isLastSourceSegment
1088
- ? currentVL.length
1089
- : Math.max(0, currentVL.length - 1);
1090
- const isLastTargetSegment = targetVisualLine === visualLines.length - 1 ||
1091
- visualLines[targetVisualLine + 1]?.logicalLine !== targetVL.logicalLine;
1092
- const targetMaxVisualCol = isLastTargetSegment
1093
- ? targetVL.length
1094
- : Math.max(0, targetVL.length - 1);
1095
- const moveToVisualCol = this.computeVerticalMoveColumn(currentVisualCol, sourceMaxVisualCol, targetMaxVisualCol);
1096
- // Set cursor position
1097
- this.state.cursorLine = targetVL.logicalLine;
1098
- const targetCol = targetVL.startCol + moveToVisualCol;
1099
- const logicalLine = this.state.lines[targetVL.logicalLine] || "";
1100
- this.state.cursorCol = Math.min(targetCol, logicalLine.length);
1093
+ if (!(currentVL && targetVL))
1094
+ return;
1095
+ // When the cursor was snapped to a segment start, resolve the pre-snap
1096
+ // position against the VL it belongs to. This gives the correct visual
1097
+ // column even after a resize reshuffles VLs.
1098
+ let currentVisualCol;
1099
+ if (this.snappedFromCursorCol !== null) {
1100
+ const vlIndex = this.findVisualLineAt(visualLines, currentVL.logicalLine, this.snappedFromCursorCol);
1101
+ currentVisualCol = this.snappedFromCursorCol - visualLines[vlIndex].startCol;
1101
1102
  }
1103
+ else {
1104
+ currentVisualCol = this.state.cursorCol - currentVL.startCol;
1105
+ }
1106
+ // For non-last segments, clamp to length-1 to stay within the segment
1107
+ const isLastSourceSegment = currentVisualLine === visualLines.length - 1 ||
1108
+ visualLines[currentVisualLine + 1]?.logicalLine !== currentVL.logicalLine;
1109
+ const sourceMaxVisualCol = isLastSourceSegment
1110
+ ? currentVL.length
1111
+ : Math.max(0, currentVL.length - 1);
1112
+ const isLastTargetSegment = targetVisualLine === visualLines.length - 1 ||
1113
+ visualLines[targetVisualLine + 1]?.logicalLine !== targetVL.logicalLine;
1114
+ const targetMaxVisualCol = isLastTargetSegment
1115
+ ? targetVL.length
1116
+ : Math.max(0, targetVL.length - 1);
1117
+ const moveToVisualCol = this.computeVerticalMoveColumn(currentVisualCol, sourceMaxVisualCol, targetMaxVisualCol);
1118
+ // Set cursor position
1119
+ this.state.cursorLine = targetVL.logicalLine;
1120
+ const targetCol = targetVL.startCol + moveToVisualCol;
1121
+ const logicalLine = this.state.lines[targetVL.logicalLine] || "";
1122
+ this.state.cursorCol = Math.min(targetCol, logicalLine.length);
1123
+ // Snap cursor to atomic segment boundary (e.g. paste markers)
1124
+ // so the cursor never lands in the middle of a multi-grapheme unit.
1125
+ // Single-grapheme segments don't need snapping.
1126
+ const segments = [...this.segment(logicalLine)];
1127
+ for (const seg of segments) {
1128
+ if (seg.index > this.state.cursorCol)
1129
+ break;
1130
+ if (seg.segment.length <= 1)
1131
+ continue;
1132
+ if (this.state.cursorCol < seg.index + seg.segment.length) {
1133
+ const isContinuation = seg.index < targetVL.startCol;
1134
+ const isMovingDown = targetVisualLine > currentVisualLine;
1135
+ if (isContinuation && isMovingDown) {
1136
+ // The segment started on a previous visual line, and we
1137
+ // already visited it on the way down. Skip all remaining
1138
+ // continuation VLs and land on the first VL past it.
1139
+ const segEnd = seg.index + seg.segment.length;
1140
+ let next = targetVisualLine + 1;
1141
+ while (next < visualLines.length &&
1142
+ visualLines[next].logicalLine === targetVL.logicalLine &&
1143
+ visualLines[next].startCol < segEnd) {
1144
+ next++;
1145
+ }
1146
+ if (next < visualLines.length) {
1147
+ this.moveToVisualLine(visualLines, currentVisualLine, next);
1148
+ return;
1149
+ }
1150
+ }
1151
+ // Snap to the start of the segment so it gets highlighted.
1152
+ // Store the pre-snap position so the next vertical move can
1153
+ // resolve it to the correct visual column.
1154
+ this.snappedFromCursorCol = this.state.cursorCol;
1155
+ this.state.cursorCol = seg.index;
1156
+ return;
1157
+ }
1158
+ }
1159
+ // No snap occurred – we moved out of the atomic segment.
1160
+ this.snappedFromCursorCol = null;
1102
1161
  }
1103
1162
  /**
1104
1163
  * Compute the target visual column for vertical cursor movement.
@@ -1177,7 +1236,9 @@ export class Editor {
1177
1236
  this.state.cursorLine--;
1178
1237
  this.setCursorCol(previousLine.length);
1179
1238
  }
1180
- this.notifyChange();
1239
+ if (this.onChange) {
1240
+ this.onChange(this.getText());
1241
+ }
1181
1242
  }
1182
1243
  deleteToEndOfLine() {
1183
1244
  this.historyIndex = -1; // Exit history browsing mode
@@ -1200,7 +1261,9 @@ export class Editor {
1200
1261
  this.state.lines[this.state.cursorLine] = currentLine + nextLine;
1201
1262
  this.state.lines.splice(this.state.cursorLine + 1, 1);
1202
1263
  }
1203
- this.notifyChange();
1264
+ if (this.onChange) {
1265
+ this.onChange(this.getText());
1266
+ }
1204
1267
  }
1205
1268
  deleteWordBackwards() {
1206
1269
  this.historyIndex = -1; // Exit history browsing mode
@@ -1234,7 +1297,9 @@ export class Editor {
1234
1297
  currentLine.slice(0, deleteFrom) + currentLine.slice(this.state.cursorCol);
1235
1298
  this.setCursorCol(deleteFrom);
1236
1299
  }
1237
- this.notifyChange();
1300
+ if (this.onChange) {
1301
+ this.onChange(this.getText());
1302
+ }
1238
1303
  }
1239
1304
  deleteWordForward() {
1240
1305
  this.historyIndex = -1; // Exit history browsing mode
@@ -1265,19 +1330,20 @@ export class Editor {
1265
1330
  this.state.lines[this.state.cursorLine] =
1266
1331
  currentLine.slice(0, this.state.cursorCol) + currentLine.slice(deleteTo);
1267
1332
  }
1268
- this.notifyChange();
1333
+ if (this.onChange) {
1334
+ this.onChange(this.getText());
1335
+ }
1269
1336
  }
1270
1337
  handleForwardDelete() {
1271
1338
  this.historyIndex = -1; // Exit history browsing mode
1272
1339
  this.lastAction = null;
1273
- this.ghostTextValue = null;
1274
1340
  const currentLine = this.state.lines[this.state.cursorLine] || "";
1275
1341
  if (this.state.cursorCol < currentLine.length) {
1276
1342
  this.pushUndoSnapshot();
1277
1343
  // Delete grapheme at cursor position (handles emojis, combining characters, etc.)
1278
1344
  const afterCursor = currentLine.slice(this.state.cursorCol);
1279
1345
  // Find the first grapheme at cursor
1280
- const graphemes = [...segmenter.segment(afterCursor)];
1346
+ const graphemes = [...this.segment(afterCursor)];
1281
1347
  const firstGrapheme = graphemes[0];
1282
1348
  const graphemeLength = firstGrapheme ? firstGrapheme.segment.length : 1;
1283
1349
  const before = currentLine.slice(0, this.state.cursorCol);
@@ -1291,7 +1357,9 @@ export class Editor {
1291
1357
  this.state.lines[this.state.cursorLine] = currentLine + nextLine;
1292
1358
  this.state.lines.splice(this.state.cursorLine + 1, 1);
1293
1359
  }
1294
- this.notifyChange();
1360
+ if (this.onChange) {
1361
+ this.onChange(this.getText());
1362
+ }
1295
1363
  // Update or re-trigger autocomplete after forward delete
1296
1364
  if (this.autocompleteState) {
1297
1365
  this.updateAutocomplete();
@@ -1330,7 +1398,7 @@ export class Editor {
1330
1398
  }
1331
1399
  else {
1332
1400
  // Line needs wrapping - use word-aware wrapping
1333
- const chunks = wordWrapLine(line, width);
1401
+ const chunks = wordWrapLine(line, width, [...this.segment(line)]);
1334
1402
  for (const chunk of chunks) {
1335
1403
  visualLines.push({
1336
1404
  logicalLine: i,
@@ -1343,27 +1411,29 @@ export class Editor {
1343
1411
  return visualLines;
1344
1412
  }
1345
1413
  /**
1346
- * Find the visual line index for the current cursor position.
1414
+ * Find the visual line index that contains the given logical position.
1347
1415
  */
1348
- findCurrentVisualLine(visualLines) {
1416
+ findVisualLineAt(visualLines, line, col) {
1349
1417
  for (let i = 0; i < visualLines.length; i++) {
1350
1418
  const vl = visualLines[i];
1351
- if (!vl)
1419
+ if (!vl || vl.logicalLine !== line)
1352
1420
  continue;
1353
- if (vl.logicalLine === this.state.cursorLine) {
1354
- const colInSegment = this.state.cursorCol - vl.startCol;
1355
- // Cursor is in this segment if it's within range
1356
- // For the last segment of a logical line, cursor can be at length (end position)
1357
- const isLastSegmentOfLine = i === visualLines.length - 1 || visualLines[i + 1]?.logicalLine !== vl.logicalLine;
1358
- if (colInSegment >= 0 &&
1359
- (colInSegment < vl.length || (isLastSegmentOfLine && colInSegment <= vl.length))) {
1360
- return i;
1361
- }
1421
+ const offset = col - vl.startCol;
1422
+ // Cursor is in this segment if it's within range. For the last
1423
+ // segment of a logical line, cursor can be at length (end position)
1424
+ const isLastSegmentOfLine = i === visualLines.length - 1 || visualLines[i + 1]?.logicalLine !== vl.logicalLine;
1425
+ if (offset >= 0 && (offset < vl.length || (isLastSegmentOfLine && offset === vl.length))) {
1426
+ return i;
1362
1427
  }
1363
1428
  }
1364
- // Fallback: return last visual line
1365
1429
  return visualLines.length - 1;
1366
1430
  }
1431
+ /**
1432
+ * Find the visual line index for the current cursor position.
1433
+ */
1434
+ findCurrentVisualLine(visualLines) {
1435
+ return this.findVisualLineAt(visualLines, this.state.cursorLine, this.state.cursorCol);
1436
+ }
1367
1437
  moveCursor(deltaLine, deltaCol) {
1368
1438
  this.lastAction = null;
1369
1439
  const visualLines = this.buildVisualLineMap(this.lastWidth);
@@ -1380,7 +1450,7 @@ export class Editor {
1380
1450
  // Moving right - move by one grapheme (handles emojis, combining characters, etc.)
1381
1451
  if (this.state.cursorCol < currentLine.length) {
1382
1452
  const afterCursor = currentLine.slice(this.state.cursorCol);
1383
- const graphemes = [...segmenter.segment(afterCursor)];
1453
+ const graphemes = [...this.segment(afterCursor)];
1384
1454
  const firstGrapheme = graphemes[0];
1385
1455
  this.setCursorCol(this.state.cursorCol + (firstGrapheme ? firstGrapheme.segment.length : 1));
1386
1456
  }
@@ -1401,7 +1471,7 @@ export class Editor {
1401
1471
  // Moving left - move by one grapheme (handles emojis, combining characters, etc.)
1402
1472
  if (this.state.cursorCol > 0) {
1403
1473
  const beforeCursor = currentLine.slice(0, this.state.cursorCol);
1404
- const graphemes = [...segmenter.segment(beforeCursor)];
1474
+ const graphemes = [...this.segment(beforeCursor)];
1405
1475
  const lastGrapheme = graphemes[graphemes.length - 1];
1406
1476
  this.setCursorCol(this.state.cursorCol - (lastGrapheme ? lastGrapheme.segment.length : 1));
1407
1477
  }
@@ -1440,19 +1510,25 @@ export class Editor {
1440
1510
  return;
1441
1511
  }
1442
1512
  const textBeforeCursor = currentLine.slice(0, this.state.cursorCol);
1443
- const graphemes = [...segmenter.segment(textBeforeCursor)];
1513
+ const graphemes = [...this.segment(textBeforeCursor)];
1444
1514
  let newCol = this.state.cursorCol;
1445
1515
  // Skip trailing whitespace
1446
1516
  while (graphemes.length > 0 &&
1517
+ !isPasteMarker(graphemes[graphemes.length - 1]?.segment || "") &&
1447
1518
  isWhitespaceChar(graphemes[graphemes.length - 1]?.segment || "")) {
1448
1519
  newCol -= graphemes.pop()?.segment.length || 0;
1449
1520
  }
1450
1521
  if (graphemes.length > 0) {
1451
1522
  const lastGrapheme = graphemes[graphemes.length - 1]?.segment || "";
1452
- if (isPunctuationChar(lastGrapheme)) {
1523
+ if (isPasteMarker(lastGrapheme)) {
1524
+ // Paste marker is a single atomic word
1525
+ newCol -= graphemes.pop()?.segment.length || 0;
1526
+ }
1527
+ else if (isPunctuationChar(lastGrapheme)) {
1453
1528
  // Skip punctuation run
1454
1529
  while (graphemes.length > 0 &&
1455
- isPunctuationChar(graphemes[graphemes.length - 1]?.segment || "")) {
1530
+ isPunctuationChar(graphemes[graphemes.length - 1]?.segment || "") &&
1531
+ !isPasteMarker(graphemes[graphemes.length - 1]?.segment || "")) {
1456
1532
  newCol -= graphemes.pop()?.segment.length || 0;
1457
1533
  }
1458
1534
  }
@@ -1460,7 +1536,8 @@ export class Editor {
1460
1536
  // Skip word run
1461
1537
  while (graphemes.length > 0 &&
1462
1538
  !isWhitespaceChar(graphemes[graphemes.length - 1]?.segment || "") &&
1463
- !isPunctuationChar(graphemes[graphemes.length - 1]?.segment || "")) {
1539
+ !isPunctuationChar(graphemes[graphemes.length - 1]?.segment || "") &&
1540
+ !isPasteMarker(graphemes[graphemes.length - 1]?.segment || "")) {
1464
1541
  newCol -= graphemes.pop()?.segment.length || 0;
1465
1542
  }
1466
1543
  }
@@ -1528,7 +1605,9 @@ export class Editor {
1528
1605
  this.state.cursorLine = lastLineIndex;
1529
1606
  this.setCursorCol((lines[lines.length - 1] || "").length);
1530
1607
  }
1531
- this.notifyChange();
1608
+ if (this.onChange) {
1609
+ this.onChange(this.getText());
1610
+ }
1532
1611
  }
1533
1612
  /**
1534
1613
  * Delete the previously yanked text (used by yank-pop).
@@ -1562,7 +1641,9 @@ export class Editor {
1562
1641
  this.state.cursorLine = startLine;
1563
1642
  this.setCursorCol(startCol);
1564
1643
  }
1565
- this.notifyChange();
1644
+ if (this.onChange) {
1645
+ this.onChange(this.getText());
1646
+ }
1566
1647
  }
1567
1648
  pushUndoSnapshot() {
1568
1649
  this.undoStack.push(this.state);
@@ -1575,7 +1656,9 @@ export class Editor {
1575
1656
  Object.assign(this.state, snapshot);
1576
1657
  this.lastAction = null;
1577
1658
  this.preferredVisualCol = null;
1578
- this.notifyChange();
1659
+ if (this.onChange) {
1660
+ this.onChange(this.getText());
1661
+ }
1579
1662
  }
1580
1663
  /**
1581
1664
  * Jump to the first occurrence of a character in the specified direction.
@@ -1617,20 +1700,28 @@ export class Editor {
1617
1700
  return;
1618
1701
  }
1619
1702
  const textAfterCursor = currentLine.slice(this.state.cursorCol);
1620
- const segments = segmenter.segment(textAfterCursor);
1703
+ const segments = this.segment(textAfterCursor);
1621
1704
  const iterator = segments[Symbol.iterator]();
1622
1705
  let next = iterator.next();
1623
1706
  let newCol = this.state.cursorCol;
1624
1707
  // Skip leading whitespace
1625
- while (!next.done && isWhitespaceChar(next.value.segment)) {
1708
+ while (!next.done &&
1709
+ !isPasteMarker(next.value.segment) &&
1710
+ isWhitespaceChar(next.value.segment)) {
1626
1711
  newCol += next.value.segment.length;
1627
1712
  next = iterator.next();
1628
1713
  }
1629
1714
  if (!next.done) {
1630
1715
  const firstGrapheme = next.value.segment;
1631
- if (isPunctuationChar(firstGrapheme)) {
1716
+ if (isPasteMarker(firstGrapheme)) {
1717
+ // Paste marker is a single atomic word
1718
+ newCol += firstGrapheme.length;
1719
+ }
1720
+ else if (isPunctuationChar(firstGrapheme)) {
1632
1721
  // Skip punctuation run
1633
- while (!next.done && isPunctuationChar(next.value.segment)) {
1722
+ while (!next.done &&
1723
+ isPunctuationChar(next.value.segment) &&
1724
+ !isPasteMarker(next.value.segment)) {
1634
1725
  newCol += next.value.segment.length;
1635
1726
  next = iterator.next();
1636
1727
  }
@@ -1639,7 +1730,8 @@ export class Editor {
1639
1730
  // Skip word run
1640
1731
  while (!next.done &&
1641
1732
  !isWhitespaceChar(next.value.segment) &&
1642
- !isPunctuationChar(next.value.segment)) {
1733
+ !isPunctuationChar(next.value.segment) &&
1734
+ !isPasteMarker(next.value.segment)) {
1643
1735
  newCol += next.value.segment.length;
1644
1736
  next = iterator.next();
1645
1737
  }
@@ -1663,34 +1755,44 @@ export class Editor {
1663
1755
  return this.isSlashMenuAllowed() && textBeforeCursor.trimStart().startsWith("/");
1664
1756
  }
1665
1757
  // Autocomplete methods
1666
- tryTriggerAutocomplete(explicitTab = false) {
1667
- if (!this.autocompleteProvider)
1668
- return;
1669
- // Check if we should trigger file completion on Tab
1670
- if (explicitTab) {
1671
- const provider = this.autocompleteProvider;
1672
- const shouldTrigger = !provider.shouldTriggerFileCompletion ||
1673
- provider.shouldTriggerFileCompletion(this.state.lines, this.state.cursorLine, this.state.cursorCol);
1674
- if (!shouldTrigger) {
1675
- return;
1758
+ /**
1759
+ * Find the best autocomplete item index for the given prefix.
1760
+ * Returns -1 if no match is found.
1761
+ *
1762
+ * Match priority:
1763
+ * 1. Exact match (prefix === item.value) -> always selected
1764
+ * 2. Prefix match -> first item whose value starts with prefix
1765
+ * 3. No match -> -1 (keep default highlight)
1766
+ *
1767
+ * Matching is case-sensitive and checks item.value only.
1768
+ */
1769
+ getBestAutocompleteMatchIndex(items, prefix) {
1770
+ if (!prefix)
1771
+ return -1;
1772
+ let firstPrefixIndex = -1;
1773
+ for (let i = 0; i < items.length; i++) {
1774
+ const value = items[i].value;
1775
+ if (value === prefix) {
1776
+ return i; // Exact match always wins
1777
+ }
1778
+ if (firstPrefixIndex === -1 && value.startsWith(prefix)) {
1779
+ firstPrefixIndex = i;
1676
1780
  }
1677
1781
  }
1678
- const suggestions = this.autocompleteProvider.getSuggestions(this.state.lines, this.state.cursorLine, this.state.cursorCol);
1679
- if (suggestions && suggestions.items.length > 0) {
1680
- this.autocompletePrefix = suggestions.prefix;
1681
- this.autocompleteList = new SelectList(suggestions.items, this.autocompleteMaxVisible, this.theme.selectList);
1682
- this.autocompleteState = "regular";
1683
- }
1684
- else {
1685
- this.cancelAutocomplete();
1686
- }
1782
+ return firstPrefixIndex;
1783
+ }
1784
+ createAutocompleteList(prefix, items) {
1785
+ const layout = prefix.startsWith("/") ? SLASH_COMMAND_SELECT_LIST_LAYOUT : undefined;
1786
+ return new SelectList(items, this.autocompleteMaxVisible, this.theme.selectList, layout);
1787
+ }
1788
+ tryTriggerAutocomplete(explicitTab = false) {
1789
+ this.requestAutocomplete({ force: false, explicitTab });
1687
1790
  }
1688
1791
  handleTabCompletion() {
1689
1792
  if (!this.autocompleteProvider)
1690
1793
  return;
1691
1794
  const currentLine = this.state.lines[this.state.cursorLine] || "";
1692
1795
  const beforeCursor = currentLine.slice(0, this.state.cursorCol);
1693
- // Check if we're in a slash command context
1694
1796
  if (this.isInSlashCommandContext(beforeCursor) && !beforeCursor.trimStart().includes(" ")) {
1695
1797
  this.handleSlashCommandCompletion();
1696
1798
  }
@@ -1699,68 +1801,130 @@ export class Editor {
1699
1801
  }
1700
1802
  }
1701
1803
  handleSlashCommandCompletion() {
1702
- this.tryTriggerAutocomplete(true);
1804
+ this.requestAutocomplete({ force: false, explicitTab: true });
1703
1805
  }
1704
- /*
1705
- https://github.com/EsotericSoftware/spine-runtimes/actions/runs/19536643416/job/559322883
1706
- 17 this job fails with https://github.com/EsotericSoftware/spine-runtimes/actions/runs/19
1707
- 536643416/job/55932288317 havea look at .gi
1708
- */
1709
1806
  forceFileAutocomplete(explicitTab = false) {
1807
+ this.requestAutocomplete({ force: true, explicitTab });
1808
+ }
1809
+ requestAutocomplete(options) {
1710
1810
  if (!this.autocompleteProvider)
1711
1811
  return;
1712
- // Check if provider supports force file suggestions via runtime check
1713
- const provider = this.autocompleteProvider;
1714
- if (typeof provider.getForceFileSuggestions !== "function") {
1715
- this.tryTriggerAutocomplete(true);
1812
+ if (options.force) {
1813
+ const provider = this.autocompleteProvider;
1814
+ const shouldTrigger = !provider.shouldTriggerFileCompletion ||
1815
+ provider.shouldTriggerFileCompletion(this.state.lines, this.state.cursorLine, this.state.cursorCol);
1816
+ if (!shouldTrigger) {
1817
+ return;
1818
+ }
1819
+ }
1820
+ this.cancelAutocompleteRequest();
1821
+ const startToken = ++this.autocompleteStartToken;
1822
+ const debounceMs = this.getAutocompleteDebounceMs(options);
1823
+ if (debounceMs > 0) {
1824
+ this.autocompleteDebounceTimer = setTimeout(() => {
1825
+ this.autocompleteDebounceTimer = undefined;
1826
+ void this.startAutocompleteRequest(startToken, options);
1827
+ }, debounceMs);
1716
1828
  return;
1717
1829
  }
1718
- const suggestions = provider.getForceFileSuggestions(this.state.lines, this.state.cursorLine, this.state.cursorCol);
1719
- if (suggestions && suggestions.items.length > 0) {
1720
- // If there's exactly one suggestion, apply it immediately
1721
- if (explicitTab && suggestions.items.length === 1) {
1722
- const item = suggestions.items[0];
1723
- this.pushUndoSnapshot();
1724
- this.lastAction = null;
1725
- const result = this.autocompleteProvider.applyCompletion(this.state.lines, this.state.cursorLine, this.state.cursorCol, item, suggestions.prefix);
1726
- this.state.lines = result.lines;
1727
- this.state.cursorLine = result.cursorLine;
1728
- this.setCursorCol(result.cursorCol);
1729
- this.notifyChange();
1830
+ void this.startAutocompleteRequest(startToken, options);
1831
+ }
1832
+ async startAutocompleteRequest(startToken, options) {
1833
+ const previousTask = this.autocompleteRequestTask;
1834
+ this.autocompleteRequestTask = (async () => {
1835
+ await previousTask;
1836
+ if (startToken !== this.autocompleteStartToken || !this.autocompleteProvider) {
1730
1837
  return;
1731
1838
  }
1732
- this.autocompletePrefix = suggestions.prefix;
1733
- this.autocompleteList = new SelectList(suggestions.items, this.autocompleteMaxVisible, this.theme.selectList);
1734
- this.autocompleteState = "force";
1839
+ const controller = new AbortController();
1840
+ this.autocompleteAbort = controller;
1841
+ const requestId = ++this.autocompleteRequestId;
1842
+ const snapshotText = this.getText();
1843
+ const snapshotLine = this.state.cursorLine;
1844
+ const snapshotCol = this.state.cursorCol;
1845
+ await this.runAutocompleteRequest(requestId, controller, snapshotText, snapshotLine, snapshotCol, options);
1846
+ })();
1847
+ await this.autocompleteRequestTask;
1848
+ }
1849
+ getAutocompleteDebounceMs(options) {
1850
+ if (options.explicitTab || options.force) {
1851
+ return 0;
1735
1852
  }
1736
- else {
1853
+ const currentLine = this.state.lines[this.state.cursorLine] || "";
1854
+ const textBeforeCursor = currentLine.slice(0, this.state.cursorCol);
1855
+ const isAttachmentContext = /(?:^|[ \t])@(?:"[^"]*|[^\s]*)$/.test(textBeforeCursor);
1856
+ return isAttachmentContext ? ATTACHMENT_AUTOCOMPLETE_DEBOUNCE_MS : 0;
1857
+ }
1858
+ async runAutocompleteRequest(requestId, controller, snapshotText, snapshotLine, snapshotCol, options) {
1859
+ if (!this.autocompleteProvider)
1860
+ return;
1861
+ const suggestions = await this.autocompleteProvider.getSuggestions(this.state.lines, this.state.cursorLine, this.state.cursorCol, { signal: controller.signal, force: options.force });
1862
+ if (!this.isAutocompleteRequestCurrent(requestId, controller, snapshotText, snapshotLine, snapshotCol)) {
1863
+ return;
1864
+ }
1865
+ this.autocompleteAbort = undefined;
1866
+ if (!suggestions || !Array.isArray(suggestions.items) || suggestions.items.length === 0) {
1737
1867
  this.cancelAutocomplete();
1868
+ this.tui.requestRender();
1869
+ return;
1738
1870
  }
1871
+ if (options.force && options.explicitTab && suggestions.items.length === 1) {
1872
+ const item = suggestions.items[0];
1873
+ this.pushUndoSnapshot();
1874
+ this.lastAction = null;
1875
+ const result = this.autocompleteProvider.applyCompletion(this.state.lines, this.state.cursorLine, this.state.cursorCol, item, suggestions.prefix);
1876
+ this.state.lines = result.lines;
1877
+ this.state.cursorLine = result.cursorLine;
1878
+ this.setCursorCol(result.cursorCol);
1879
+ if (this.onChange)
1880
+ this.onChange(this.getText());
1881
+ this.tui.requestRender();
1882
+ return;
1883
+ }
1884
+ this.applyAutocompleteSuggestions(suggestions, options.force ? "force" : "regular");
1885
+ this.tui.requestRender();
1739
1886
  }
1740
- cancelAutocomplete() {
1887
+ isAutocompleteRequestCurrent(requestId, controller, snapshotText, snapshotLine, snapshotCol) {
1888
+ return (!controller.signal.aborted &&
1889
+ requestId === this.autocompleteRequestId &&
1890
+ this.getText() === snapshotText &&
1891
+ this.state.cursorLine === snapshotLine &&
1892
+ this.state.cursorCol === snapshotCol);
1893
+ }
1894
+ applyAutocompleteSuggestions(suggestions, state) {
1895
+ this.autocompletePrefix = suggestions.prefix;
1896
+ this.autocompleteList = this.createAutocompleteList(suggestions.prefix, suggestions.items);
1897
+ const bestMatchIndex = this.getBestAutocompleteMatchIndex(suggestions.items, suggestions.prefix);
1898
+ if (bestMatchIndex >= 0) {
1899
+ this.autocompleteList.setSelectedIndex(bestMatchIndex);
1900
+ }
1901
+ this.autocompleteState = state;
1902
+ }
1903
+ cancelAutocompleteRequest() {
1904
+ this.autocompleteStartToken += 1;
1905
+ if (this.autocompleteDebounceTimer) {
1906
+ clearTimeout(this.autocompleteDebounceTimer);
1907
+ this.autocompleteDebounceTimer = undefined;
1908
+ }
1909
+ this.autocompleteAbort?.abort();
1910
+ this.autocompleteAbort = undefined;
1911
+ }
1912
+ clearAutocompleteUi() {
1741
1913
  this.autocompleteState = null;
1742
1914
  this.autocompleteList = undefined;
1743
1915
  this.autocompletePrefix = "";
1744
1916
  }
1917
+ cancelAutocomplete() {
1918
+ this.cancelAutocompleteRequest();
1919
+ this.clearAutocompleteUi();
1920
+ }
1745
1921
  isShowingAutocomplete() {
1746
1922
  return this.autocompleteState !== null;
1747
1923
  }
1748
1924
  updateAutocomplete() {
1749
1925
  if (!this.autocompleteState || !this.autocompleteProvider)
1750
1926
  return;
1751
- if (this.autocompleteState === "force") {
1752
- this.forceFileAutocomplete();
1753
- return;
1754
- }
1755
- const suggestions = this.autocompleteProvider.getSuggestions(this.state.lines, this.state.cursorLine, this.state.cursorCol);
1756
- if (suggestions && suggestions.items.length > 0) {
1757
- this.autocompletePrefix = suggestions.prefix;
1758
- // Always create new SelectList to ensure update
1759
- this.autocompleteList = new SelectList(suggestions.items, this.autocompleteMaxVisible, this.theme.selectList);
1760
- }
1761
- else {
1762
- this.cancelAutocomplete();
1763
- }
1927
+ this.requestAutocomplete({ force: this.autocompleteState === "force", explicitTab: false });
1764
1928
  }
1765
1929
  }
1766
1930
  //# sourceMappingURL=editor.js.map