@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.
- package/dist/cli.js +7 -4
- package/dist/cli.js.map +1 -1
- package/dist/config.d.ts +1 -1
- package/dist/config.js +1 -1
- package/dist/interactive-mode-patch.d.ts +24 -12
- package/dist/interactive-mode-patch.d.ts.map +1 -1
- package/dist/interactive-mode-patch.js +229 -146
- package/dist/interactive-mode-patch.js.map +1 -1
- package/dist/interactive-reset.d.ts +49 -0
- package/dist/interactive-reset.d.ts.map +1 -0
- package/dist/interactive-reset.js +40 -0
- package/dist/interactive-reset.js.map +1 -0
- package/dist/pi-tui-editor-patch.d.ts +10 -0
- package/dist/pi-tui-editor-patch.d.ts.map +1 -0
- package/dist/pi-tui-editor-patch.js +159 -0
- package/dist/pi-tui-editor-patch.js.map +1 -0
- package/dist/pi-tui-patch.d.ts +2 -0
- package/dist/pi-tui-patch.d.ts.map +1 -0
- package/dist/pi-tui-patch.js +563 -0
- package/dist/pi-tui-patch.js.map +1 -0
- package/dist/pi-tui-settings-list-patch.d.ts +11 -0
- package/dist/pi-tui-settings-list-patch.d.ts.map +1 -0
- package/dist/pi-tui-settings-list-patch.js +38 -0
- package/dist/pi-tui-settings-list-patch.js.map +1 -0
- package/dist/reset-diagnostics.d.ts +69 -0
- package/dist/reset-diagnostics.d.ts.map +1 -0
- package/dist/reset-diagnostics.js +41 -0
- package/dist/reset-diagnostics.js.map +1 -0
- package/dist/sdk.d.ts +5 -21
- package/dist/sdk.d.ts.map +1 -1
- package/dist/sdk.js +180 -149
- package/dist/sdk.js.map +1 -1
- package/dist/workspace-transition-interactive.d.ts +1 -0
- package/dist/workspace-transition-interactive.d.ts.map +1 -1
- package/dist/workspace-transition-interactive.js +7 -17
- package/dist/workspace-transition-interactive.js.map +1 -1
- package/extensions/__integration__/audit-findings.test.ts +4 -5
- package/extensions/_icons/index.ts +2 -4
- package/extensions/_shared/__tests__/image-metadata.test.ts +33 -0
- package/extensions/_shared/__tests__/terminal-links.test.ts +18 -0
- package/extensions/_shared/image-metadata.ts +99 -0
- package/extensions/_shared/inline-preview.ts +1 -1
- package/extensions/_shared/terminal-links.ts +22 -0
- package/extensions/ask-user-question-tool/index.ts +0 -3
- package/extensions/clear/__tests__/clear.test.ts +269 -2
- package/extensions/command-expansion/index.ts +1 -1
- package/extensions/context-files/index.ts +5 -1
- package/extensions/context-fork/__tests__/context-fork.test.ts +94 -1
- package/extensions/context-fork/extension.json +1 -1
- package/extensions/context-fork/index.ts +32 -0
- package/extensions/edit-tool-enhanced/index.ts +2 -1
- package/extensions/hooks/index.ts +33 -11
- package/extensions/loop/index.ts +14 -1
- package/extensions/lsp/index.ts +64 -13
- package/extensions/lsp/package.json +2 -2
- package/extensions/random-spinner/index.ts +7 -642
- package/extensions/read-tool-enhanced/index.ts +6 -8
- package/extensions/render-stabilizer/__tests__/render-stabilizer.test.ts +2 -3
- package/extensions/render-stabilizer/index.ts +6 -6
- package/extensions/slash-command-bridge/__tests__/slash-command-bridge.test.ts +26 -0
- package/extensions/slash-command-bridge/index.ts +14 -2
- package/extensions/subagent-tool/model-resolver.ts +274 -7
- package/extensions/tasks/commands/register-tasks-extension.ts +9 -9
- package/extensions/teams-tool/tools/register-extension.ts +1 -3
- package/extensions/web-search-tool/index.ts +2 -1
- package/extensions/write-tool-enhanced/index.ts +2 -1
- package/node_modules/@mariozechner/pi-tui/README.md +56 -34
- package/node_modules/@mariozechner/pi-tui/dist/autocomplete.d.ts +18 -13
- package/node_modules/@mariozechner/pi-tui/dist/autocomplete.d.ts.map +1 -1
- package/node_modules/@mariozechner/pi-tui/dist/autocomplete.js +182 -113
- package/node_modules/@mariozechner/pi-tui/dist/autocomplete.js.map +1 -1
- package/node_modules/@mariozechner/pi-tui/dist/components/cancellable-loader.js +3 -3
- package/node_modules/@mariozechner/pi-tui/dist/components/cancellable-loader.js.map +1 -1
- package/node_modules/@mariozechner/pi-tui/dist/components/editor.d.ts +45 -36
- package/node_modules/@mariozechner/pi-tui/dist/components/editor.d.ts.map +1 -1
- package/node_modules/@mariozechner/pi-tui/dist/components/editor.js +489 -325
- package/node_modules/@mariozechner/pi-tui/dist/components/editor.js.map +1 -1
- package/node_modules/@mariozechner/pi-tui/dist/components/image.d.ts +1 -99
- package/node_modules/@mariozechner/pi-tui/dist/components/image.d.ts.map +1 -1
- package/node_modules/@mariozechner/pi-tui/dist/components/image.js +17 -192
- package/node_modules/@mariozechner/pi-tui/dist/components/image.js.map +1 -1
- package/node_modules/@mariozechner/pi-tui/dist/components/input.d.ts.map +1 -1
- package/node_modules/@mariozechner/pi-tui/dist/components/input.js +57 -60
- package/node_modules/@mariozechner/pi-tui/dist/components/input.js.map +1 -1
- package/node_modules/@mariozechner/pi-tui/dist/components/loader.d.ts +2 -69
- package/node_modules/@mariozechner/pi-tui/dist/components/loader.d.ts.map +1 -1
- package/node_modules/@mariozechner/pi-tui/dist/components/loader.js +5 -102
- package/node_modules/@mariozechner/pi-tui/dist/components/loader.js.map +1 -1
- package/node_modules/@mariozechner/pi-tui/dist/components/markdown.d.ts.map +1 -1
- package/node_modules/@mariozechner/pi-tui/dist/components/markdown.js +111 -53
- package/node_modules/@mariozechner/pi-tui/dist/components/markdown.js.map +1 -1
- package/node_modules/@mariozechner/pi-tui/dist/components/select-list.d.ts +19 -1
- package/node_modules/@mariozechner/pi-tui/dist/components/select-list.d.ts.map +1 -1
- package/node_modules/@mariozechner/pi-tui/dist/components/select-list.js +78 -67
- package/node_modules/@mariozechner/pi-tui/dist/components/select-list.js.map +1 -1
- package/node_modules/@mariozechner/pi-tui/dist/components/settings-list.d.ts +1 -25
- package/node_modules/@mariozechner/pi-tui/dist/components/settings-list.d.ts.map +1 -1
- package/node_modules/@mariozechner/pi-tui/dist/components/settings-list.js +13 -50
- package/node_modules/@mariozechner/pi-tui/dist/components/settings-list.js.map +1 -1
- package/node_modules/@mariozechner/pi-tui/dist/index.d.ts +8 -10
- package/node_modules/@mariozechner/pi-tui/dist/index.d.ts.map +1 -1
- package/node_modules/@mariozechner/pi-tui/dist/index.js +6 -9
- package/node_modules/@mariozechner/pi-tui/dist/index.js.map +1 -1
- package/node_modules/@mariozechner/pi-tui/dist/keybindings.d.ts +108 -238
- package/node_modules/@mariozechner/pi-tui/dist/keybindings.d.ts.map +1 -1
- package/node_modules/@mariozechner/pi-tui/dist/keybindings.js +108 -365
- package/node_modules/@mariozechner/pi-tui/dist/keybindings.js.map +1 -1
- package/node_modules/@mariozechner/pi-tui/dist/keys.d.ts +33 -48
- package/node_modules/@mariozechner/pi-tui/dist/keys.d.ts.map +1 -1
- package/node_modules/@mariozechner/pi-tui/dist/keys.js +239 -155
- package/node_modules/@mariozechner/pi-tui/dist/keys.js.map +1 -1
- package/node_modules/@mariozechner/pi-tui/dist/terminal-image.d.ts +14 -94
- package/node_modules/@mariozechner/pi-tui/dist/terminal-image.d.ts.map +1 -1
- package/node_modules/@mariozechner/pi-tui/dist/terminal-image.js +44 -186
- package/node_modules/@mariozechner/pi-tui/dist/terminal-image.js.map +1 -1
- package/node_modules/@mariozechner/pi-tui/dist/terminal.d.ts +13 -58
- package/node_modules/@mariozechner/pi-tui/dist/terminal.d.ts.map +1 -1
- package/node_modules/@mariozechner/pi-tui/dist/terminal.js +78 -111
- package/node_modules/@mariozechner/pi-tui/dist/terminal.js.map +1 -1
- package/node_modules/@mariozechner/pi-tui/dist/tui.d.ts +24 -110
- package/node_modules/@mariozechner/pi-tui/dist/tui.d.ts.map +1 -1
- package/node_modules/@mariozechner/pi-tui/dist/tui.js +188 -435
- package/node_modules/@mariozechner/pi-tui/dist/tui.js.map +1 -1
- package/node_modules/@mariozechner/pi-tui/dist/utils.d.ts +0 -18
- package/node_modules/@mariozechner/pi-tui/dist/utils.d.ts.map +1 -1
- package/node_modules/@mariozechner/pi-tui/dist/utils.js +251 -119
- package/node_modules/@mariozechner/pi-tui/dist/utils.js.map +1 -1
- package/node_modules/@mariozechner/pi-tui/package.json +6 -6
- package/node_modules/@mariozechner/pi-tui/src/__tests__/__snapshots__/render.test.ts.snap +3 -40
- package/node_modules/@mariozechner/pi-tui/src/__tests__/image-component.test.ts +71 -81
- package/node_modules/@mariozechner/pi-tui/src/__tests__/render.test.ts +0 -33
- package/node_modules/@mariozechner/pi-tui/src/__tests__/terminal-image.test.ts +93 -334
- package/node_modules/@mariozechner/pi-tui/src/__tests__/tui-render-scheduling.test.ts +1 -1
- package/node_modules/@mariozechner/pi-tui/src/__tests__/utils.test.ts +11 -196
- package/node_modules/@mariozechner/pi-tui/src/autocomplete.ts +228 -142
- package/node_modules/@mariozechner/pi-tui/src/components/cancellable-loader.ts +3 -3
- package/node_modules/@mariozechner/pi-tui/src/components/editor.ts +624 -390
- package/node_modules/@mariozechner/pi-tui/src/components/image.ts +17 -227
- package/node_modules/@mariozechner/pi-tui/src/components/input.ts +71 -63
- package/node_modules/@mariozechner/pi-tui/src/components/loader.ts +5 -137
- package/node_modules/@mariozechner/pi-tui/src/components/markdown.ts +143 -52
- package/node_modules/@mariozechner/pi-tui/src/components/select-list.ts +136 -70
- package/node_modules/@mariozechner/pi-tui/src/components/settings-list.ts +12 -51
- package/node_modules/@mariozechner/pi-tui/src/index.ts +17 -36
- package/node_modules/@mariozechner/pi-tui/src/keybindings.ts +148 -421
- package/node_modules/@mariozechner/pi-tui/src/keys.ts +253 -181
- package/node_modules/@mariozechner/pi-tui/src/terminal-image.ts +51 -252
- package/node_modules/@mariozechner/pi-tui/src/terminal.ts +78 -133
- package/node_modules/@mariozechner/pi-tui/src/tui.ts +202 -478
- package/node_modules/@mariozechner/pi-tui/src/utils.ts +289 -125
- package/node_modules/@mariozechner/pi-tui/tsconfig.build.json +1 -0
- package/package.json +13 -13
- package/packages/tallow-tui/node_modules/@types/mime-types/README.md +8 -2
- package/packages/tallow-tui/node_modules/@types/mime-types/index.d.ts +6 -0
- package/packages/tallow-tui/node_modules/@types/mime-types/package.json +9 -3
- package/packages/tallow-tui/node_modules/get-east-asian-width/lookup-data.js +18 -0
- package/packages/tallow-tui/node_modules/get-east-asian-width/lookup.js +116 -384
- package/packages/tallow-tui/node_modules/get-east-asian-width/package.json +5 -4
- package/packages/tallow-tui/node_modules/get-east-asian-width/utilities.js +24 -0
- package/packages/tallow-tui/node_modules/marked/README.md +5 -4
- package/packages/tallow-tui/node_modules/marked/bin/main.js +10 -8
- package/packages/tallow-tui/node_modules/marked/bin/marked.js +2 -1
- package/packages/tallow-tui/node_modules/marked/lib/marked.d.ts +156 -125
- package/packages/tallow-tui/node_modules/marked/lib/marked.esm.js +67 -2179
- package/packages/tallow-tui/node_modules/marked/lib/marked.esm.js.map +3 -3
- package/packages/tallow-tui/node_modules/marked/lib/marked.umd.js +67 -2201
- package/packages/tallow-tui/node_modules/marked/lib/marked.umd.js.map +3 -3
- package/packages/tallow-tui/node_modules/marked/man/marked.1 +4 -2
- package/packages/tallow-tui/node_modules/marked/man/marked.1.md +2 -1
- package/packages/tallow-tui/node_modules/marked/package.json +26 -34
- package/skills/tallow-expert/SKILL.md +1 -3
- package/node_modules/@mariozechner/pi-tui/dist/border-styles.d.ts +0 -32
- package/node_modules/@mariozechner/pi-tui/dist/border-styles.d.ts.map +0 -1
- package/node_modules/@mariozechner/pi-tui/dist/border-styles.js +0 -46
- package/node_modules/@mariozechner/pi-tui/dist/border-styles.js.map +0 -1
- package/node_modules/@mariozechner/pi-tui/dist/components/bordered-box.d.ts +0 -52
- package/node_modules/@mariozechner/pi-tui/dist/components/bordered-box.d.ts.map +0 -1
- package/node_modules/@mariozechner/pi-tui/dist/components/bordered-box.js +0 -89
- package/node_modules/@mariozechner/pi-tui/dist/components/bordered-box.js.map +0 -1
- package/node_modules/@mariozechner/pi-tui/dist/test-utils/capability-env.d.ts +0 -14
- package/node_modules/@mariozechner/pi-tui/dist/test-utils/capability-env.d.ts.map +0 -1
- package/node_modules/@mariozechner/pi-tui/dist/test-utils/capability-env.js +0 -55
- package/node_modules/@mariozechner/pi-tui/dist/test-utils/capability-env.js.map +0 -1
- package/node_modules/@mariozechner/pi-tui/src/__tests__/editor-change-listener.test.ts +0 -121
- package/node_modules/@mariozechner/pi-tui/src/__tests__/editor-ghost-text.test.ts +0 -112
- package/node_modules/@mariozechner/pi-tui/src/__tests__/mouse-events.test.ts +0 -134
- package/node_modules/@mariozechner/pi-tui/src/__tests__/settings-list.test.ts +0 -81
- package/node_modules/@mariozechner/pi-tui/src/__tests__/tui-diff-regression.test.ts +0 -555
- package/node_modules/@mariozechner/pi-tui/src/border-styles.ts +0 -60
- package/node_modules/@mariozechner/pi-tui/src/components/bordered-box.ts +0 -113
- package/node_modules/@mariozechner/pi-tui/src/test-utils/capability-env.ts +0 -56
- package/packages/tallow-tui/node_modules/marked/lib/marked.cjs +0 -2211
- package/packages/tallow-tui/node_modules/marked/lib/marked.cjs.map +0 -7
- package/packages/tallow-tui/node_modules/marked/lib/marked.d.cts +0 -728
- package/packages/tallow-tui/node_modules/marked/marked.min.js +0 -69
|
@@ -1,13 +1,91 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
|
|
3
|
-
|
|
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 {
|
|
8
|
-
|
|
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
|
-
|
|
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(
|
|
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 = [...
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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 = [...
|
|
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 =
|
|
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 (
|
|
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, "
|
|
629
|
+
if (kb.matches(data, "tui.select.cancel")) {
|
|
542
630
|
this.cancelAutocomplete();
|
|
543
631
|
return;
|
|
544
632
|
}
|
|
545
633
|
|
|
546
|
-
if (kb.matches(data, "
|
|
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.
|
|
655
|
+
if (this.onChange) this.onChange(this.getText());
|
|
568
656
|
}
|
|
569
657
|
return;
|
|
570
658
|
}
|
|
571
659
|
|
|
572
|
-
if (kb.matches(data, "
|
|
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.
|
|
681
|
+
if (this.onChange) this.onChange(this.getText());
|
|
594
682
|
return;
|
|
595
683
|
}
|
|
596
684
|
}
|
|
597
685
|
}
|
|
598
686
|
}
|
|
599
687
|
|
|
600
|
-
//
|
|
601
|
-
if (kb.matches(data, "
|
|
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
|
-
|
|
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() !==
|
|
981
|
+
if (this.getText() !== normalized) {
|
|
906
982
|
this.pushUndoSnapshot();
|
|
907
983
|
}
|
|
908
|
-
this.setTextInternal(
|
|
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 =
|
|
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.
|
|
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.
|
|
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 =
|
|
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 =
|
|
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
|
|
1143
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
1191
|
-
|
|
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.
|
|
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 = [...
|
|
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.
|
|
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
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
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
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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 = [...
|
|
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.
|
|
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
|
|
1702
|
+
* Find the visual line index that contains the given logical position.
|
|
1614
1703
|
*/
|
|
1615
|
-
private
|
|
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
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
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 = [...
|
|
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 = [...
|
|
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 = [...
|
|
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 (
|
|
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.
|
|
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.
|
|
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.
|
|
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 =
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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
|
-
|
|
1999
|
-
|
|
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
|
-
|
|
2002
|
-
|
|
2003
|
-
|
|
2004
|
-
const
|
|
2005
|
-
|
|
2006
|
-
|
|
2007
|
-
|
|
2008
|
-
|
|
2009
|
-
|
|
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
|
-
|
|
2017
|
-
|
|
2018
|
-
this.state.cursorLine,
|
|
2019
|
-
this.state.cursorCol
|
|
2020
|
-
);
|
|
2145
|
+
return firstPrefixIndex;
|
|
2146
|
+
}
|
|
2021
2147
|
|
|
2022
|
-
|
|
2023
|
-
|
|
2024
|
-
|
|
2025
|
-
|
|
2026
|
-
|
|
2027
|
-
|
|
2028
|
-
|
|
2029
|
-
|
|
2030
|
-
|
|
2031
|
-
|
|
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.
|
|
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
|
-
|
|
2062
|
-
|
|
2063
|
-
|
|
2064
|
-
|
|
2065
|
-
|
|
2066
|
-
|
|
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
|
-
|
|
2071
|
-
|
|
2072
|
-
this.state.cursorLine,
|
|
2073
|
-
this.state.cursorCol
|
|
2074
|
-
);
|
|
2210
|
+
void this.startAutocompleteRequest(startToken, options);
|
|
2211
|
+
}
|
|
2075
2212
|
|
|
2076
|
-
|
|
2077
|
-
|
|
2078
|
-
|
|
2079
|
-
|
|
2080
|
-
|
|
2081
|
-
|
|
2082
|
-
|
|
2083
|
-
|
|
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
|
-
|
|
2097
|
-
this.
|
|
2098
|
-
|
|
2099
|
-
|
|
2100
|
-
|
|
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
|
-
|
|
2103
|
-
|
|
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
|
|
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
|
}
|