@asm-agent/tui 0.8.2
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/README.md +795 -0
- package/dist/autocomplete.d.ts +61 -0
- package/dist/autocomplete.d.ts.map +1 -0
- package/dist/autocomplete.js +601 -0
- package/dist/autocomplete.js.map +1 -0
- package/dist/components/box.d.ts +24 -0
- package/dist/components/box.d.ts.map +1 -0
- package/dist/components/box.js +114 -0
- package/dist/components/box.js.map +1 -0
- package/dist/components/cancellable-loader.d.ts +22 -0
- package/dist/components/cancellable-loader.d.ts.map +1 -0
- package/dist/components/cancellable-loader.js +35 -0
- package/dist/components/cancellable-loader.js.map +1 -0
- package/dist/components/editor.d.ts +275 -0
- package/dist/components/editor.d.ts.map +1 -0
- package/dist/components/editor.js +1968 -0
- package/dist/components/editor.js.map +1 -0
- package/dist/components/image.d.ts +34 -0
- package/dist/components/image.d.ts.map +1 -0
- package/dist/components/image.js +96 -0
- package/dist/components/image.js.map +1 -0
- package/dist/components/input.d.ts +37 -0
- package/dist/components/input.d.ts.map +1 -0
- package/dist/components/input.js +385 -0
- package/dist/components/input.js.map +1 -0
- package/dist/components/loader.d.ts +29 -0
- package/dist/components/loader.d.ts.map +1 -0
- package/dist/components/loader.js +69 -0
- package/dist/components/loader.js.map +1 -0
- package/dist/components/markdown.d.ts +100 -0
- package/dist/components/markdown.d.ts.map +1 -0
- package/dist/components/markdown.js +773 -0
- package/dist/components/markdown.js.map +1 -0
- package/dist/components/select-list.d.ts +61 -0
- package/dist/components/select-list.d.ts.map +1 -0
- package/dist/components/select-list.js +206 -0
- package/dist/components/select-list.js.map +1 -0
- package/dist/components/settings-list.d.ts +49 -0
- package/dist/components/settings-list.d.ts.map +1 -0
- package/dist/components/settings-list.js +170 -0
- package/dist/components/settings-list.js.map +1 -0
- package/dist/components/spacer.d.ts +12 -0
- package/dist/components/spacer.d.ts.map +1 -0
- package/dist/components/spacer.js +21 -0
- package/dist/components/spacer.js.map +1 -0
- package/dist/components/text.d.ts +19 -0
- package/dist/components/text.d.ts.map +1 -0
- package/dist/components/text.js +77 -0
- package/dist/components/text.js.map +1 -0
- package/dist/components/truncated-text.d.ts +14 -0
- package/dist/components/truncated-text.d.ts.map +1 -0
- package/dist/components/truncated-text.js +44 -0
- package/dist/components/truncated-text.js.map +1 -0
- package/dist/editor-component.d.ts +51 -0
- package/dist/editor-component.d.ts.map +1 -0
- package/dist/editor-component.js +2 -0
- package/dist/editor-component.js.map +1 -0
- package/dist/fullscreen.d.ts +105 -0
- package/dist/fullscreen.d.ts.map +1 -0
- package/dist/fullscreen.js +597 -0
- package/dist/fullscreen.js.map +1 -0
- package/dist/fuzzy.d.ts +26 -0
- package/dist/fuzzy.d.ts.map +1 -0
- package/dist/fuzzy.js +114 -0
- package/dist/fuzzy.js.map +1 -0
- package/dist/index.d.ts +29 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +27 -0
- package/dist/index.js.map +1 -0
- package/dist/keybindings.d.ts +239 -0
- package/dist/keybindings.d.ts.map +1 -0
- package/dist/keybindings.js +222 -0
- package/dist/keybindings.js.map +1 -0
- package/dist/keys.d.ts +184 -0
- package/dist/keys.d.ts.map +1 -0
- package/dist/keys.js +1163 -0
- package/dist/keys.js.map +1 -0
- package/dist/kill-ring.d.ts +26 -0
- package/dist/kill-ring.d.ts.map +1 -0
- package/dist/kill-ring.js +42 -0
- package/dist/kill-ring.js.map +1 -0
- package/dist/latex.d.ts +21 -0
- package/dist/latex.d.ts.map +1 -0
- package/dist/latex.js +797 -0
- package/dist/latex.js.map +1 -0
- package/dist/mouse.d.ts +24 -0
- package/dist/mouse.d.ts.map +1 -0
- package/dist/mouse.js +34 -0
- package/dist/mouse.js.map +1 -0
- package/dist/render-cache.d.ts +9 -0
- package/dist/render-cache.d.ts.map +1 -0
- package/dist/render-cache.js +23 -0
- package/dist/render-cache.js.map +1 -0
- package/dist/selection-metadata.d.ts +22 -0
- package/dist/selection-metadata.d.ts.map +1 -0
- package/dist/selection-metadata.js +141 -0
- package/dist/selection-metadata.js.map +1 -0
- package/dist/slash-command-context.d.ts +12 -0
- package/dist/slash-command-context.d.ts.map +1 -0
- package/dist/slash-command-context.js +32 -0
- package/dist/slash-command-context.js.map +1 -0
- package/dist/stdin-buffer.d.ts +50 -0
- package/dist/stdin-buffer.d.ts.map +1 -0
- package/dist/stdin-buffer.js +313 -0
- package/dist/stdin-buffer.js.map +1 -0
- package/dist/terminal-colors.d.ts +31 -0
- package/dist/terminal-colors.d.ts.map +1 -0
- package/dist/terminal-colors.js +161 -0
- package/dist/terminal-colors.js.map +1 -0
- package/dist/terminal-image.d.ts +85 -0
- package/dist/terminal-image.d.ts.map +1 -0
- package/dist/terminal-image.js +324 -0
- package/dist/terminal-image.js.map +1 -0
- package/dist/terminal.d.ts +113 -0
- package/dist/terminal.d.ts.map +1 -0
- package/dist/terminal.js +486 -0
- package/dist/terminal.js.map +1 -0
- package/dist/tui.d.ts +310 -0
- package/dist/tui.d.ts.map +1 -0
- package/dist/tui.js +1673 -0
- package/dist/tui.js.map +1 -0
- package/dist/undo-stack.d.ts +16 -0
- package/dist/undo-stack.d.ts.map +1 -0
- package/dist/undo-stack.js +26 -0
- package/dist/undo-stack.js.map +1 -0
- package/dist/utils.d.ts +98 -0
- package/dist/utils.d.ts.map +1 -0
- package/dist/utils.js +1233 -0
- package/dist/utils.js.map +1 -0
- package/package.json +52 -0
|
@@ -0,0 +1,1968 @@
|
|
|
1
|
+
import { getKeybindings } from "../keybindings.js";
|
|
2
|
+
import { decodePrintableKey, matchesKey } from "../keys.js";
|
|
3
|
+
import { KillRing } from "../kill-ring.js";
|
|
4
|
+
import { getSlashCommandContext } from "../slash-command-context.js";
|
|
5
|
+
import { CURSOR_MARKER } from "../tui.js";
|
|
6
|
+
import { UndoStack } from "../undo-stack.js";
|
|
7
|
+
import { getSegmenter, isPunctuationChar, isWhitespaceChar, truncateToWidth, visibleWidth } from "../utils.js";
|
|
8
|
+
import { SelectList } from "./select-list.js";
|
|
9
|
+
const baseSegmenter = getSegmenter();
|
|
10
|
+
/** Regex matching paste markers like `[paste #1 +123 lines]` or `[paste #2 1234 chars]`. */
|
|
11
|
+
const PASTE_MARKER_REGEX = /\[paste #(\d+)( (\+\d+ lines|\d+ chars))?\]/g;
|
|
12
|
+
/** Non-global version for single-segment testing. */
|
|
13
|
+
const PASTE_MARKER_SINGLE = /^\[paste #(\d+)( (\+\d+ lines|\d+ chars))?\]$/;
|
|
14
|
+
/**
|
|
15
|
+
* Regex matching image markers like `[image #1]`. Kept in sync with the marker
|
|
16
|
+
* format produced by the coding agent's image-markers helper; the tui package
|
|
17
|
+
* can't depend on the coding agent, so the pattern is duplicated here.
|
|
18
|
+
*/
|
|
19
|
+
const IMAGE_MARKER_REGEX = /\[image #(\d+)\]/g;
|
|
20
|
+
/** Non-global version for single-segment testing. */
|
|
21
|
+
const IMAGE_MARKER_SINGLE = /^\[image #(\d+)\]$/;
|
|
22
|
+
/** Check if a segment is an atomic marker (paste or image) merged by segmentWithMarkers. */
|
|
23
|
+
function isAtomicMarker(segment) {
|
|
24
|
+
return segment.length >= 10 && (PASTE_MARKER_SINGLE.test(segment) || IMAGE_MARKER_SINGLE.test(segment));
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* A segmenter that wraps Intl.Segmenter and merges graphemes that fall
|
|
28
|
+
* within paste or image markers into single atomic segments. This makes cursor
|
|
29
|
+
* movement, deletion, word-wrap, etc. treat the markers as single units.
|
|
30
|
+
*
|
|
31
|
+
* Paste markers are only merged when their numeric ID exists in `validPasteIds`
|
|
32
|
+
* (so a stale `[paste #N]` typed by the user isn't treated as atomic). Image
|
|
33
|
+
* markers are self-contained and always merged.
|
|
34
|
+
*/
|
|
35
|
+
function segmentWithMarkers(text, validPasteIds) {
|
|
36
|
+
const hasPaste = validPasteIds.size > 0 && text.includes("[paste #");
|
|
37
|
+
const hasImage = text.includes("[image #");
|
|
38
|
+
if (!hasPaste && !hasImage) {
|
|
39
|
+
return baseSegmenter.segment(text);
|
|
40
|
+
}
|
|
41
|
+
const markers = [];
|
|
42
|
+
if (hasPaste) {
|
|
43
|
+
for (const m of text.matchAll(PASTE_MARKER_REGEX)) {
|
|
44
|
+
const id = Number.parseInt(m[1], 10);
|
|
45
|
+
if (!validPasteIds.has(id))
|
|
46
|
+
continue;
|
|
47
|
+
markers.push({ start: m.index, end: m.index + m[0].length });
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
if (hasImage) {
|
|
51
|
+
for (const m of text.matchAll(IMAGE_MARKER_REGEX)) {
|
|
52
|
+
markers.push({ start: m.index, end: m.index + m[0].length });
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
if (markers.length === 0) {
|
|
56
|
+
return baseSegmenter.segment(text);
|
|
57
|
+
}
|
|
58
|
+
markers.sort((a, b) => a.start - b.start);
|
|
59
|
+
const baseSegments = baseSegmenter.segment(text);
|
|
60
|
+
const result = [];
|
|
61
|
+
let markerIdx = 0;
|
|
62
|
+
for (const seg of baseSegments) {
|
|
63
|
+
while (markerIdx < markers.length && markers[markerIdx].end <= seg.index) {
|
|
64
|
+
markerIdx++;
|
|
65
|
+
}
|
|
66
|
+
const marker = markerIdx < markers.length ? markers[markerIdx] : null;
|
|
67
|
+
if (marker && seg.index >= marker.start && seg.index < marker.end) {
|
|
68
|
+
if (seg.index === marker.start) {
|
|
69
|
+
const markerText = text.slice(marker.start, marker.end);
|
|
70
|
+
result.push({
|
|
71
|
+
segment: markerText,
|
|
72
|
+
index: marker.start,
|
|
73
|
+
input: text,
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
else {
|
|
78
|
+
result.push(seg);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return result;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Split a line into word-wrapped chunks.
|
|
85
|
+
* Wraps at word boundaries when possible, falling back to character-level
|
|
86
|
+
* wrapping for words longer than the available width.
|
|
87
|
+
*
|
|
88
|
+
* @param line - The text line to wrap
|
|
89
|
+
* @param maxWidth - Maximum visible width per chunk
|
|
90
|
+
* @param preSegmented - Optional pre-segmented graphemes (e.g. with paste-marker awareness).
|
|
91
|
+
* When omitted the default Intl.Segmenter is used.
|
|
92
|
+
* @returns Array of chunks with text and position information
|
|
93
|
+
*/
|
|
94
|
+
export function wordWrapLine(line, maxWidth, preSegmented) {
|
|
95
|
+
if (!line || maxWidth <= 0) {
|
|
96
|
+
return [{ text: "", startIndex: 0, endIndex: 0 }];
|
|
97
|
+
}
|
|
98
|
+
const lineWidth = visibleWidth(line);
|
|
99
|
+
if (lineWidth <= maxWidth) {
|
|
100
|
+
return [{ text: line, startIndex: 0, endIndex: line.length }];
|
|
101
|
+
}
|
|
102
|
+
const chunks = [];
|
|
103
|
+
const segments = preSegmented ?? [...baseSegmenter.segment(line)];
|
|
104
|
+
let currentWidth = 0;
|
|
105
|
+
let chunkStart = 0;
|
|
106
|
+
// Wrap opportunity: the position after the last whitespace before a non-whitespace
|
|
107
|
+
// grapheme, i.e. where a line break is allowed.
|
|
108
|
+
let wrapOppIndex = -1;
|
|
109
|
+
let wrapOppWidth = 0;
|
|
110
|
+
for (let i = 0; i < segments.length; i++) {
|
|
111
|
+
const seg = segments[i];
|
|
112
|
+
const grapheme = seg.segment;
|
|
113
|
+
const gWidth = visibleWidth(grapheme);
|
|
114
|
+
const charIndex = seg.index;
|
|
115
|
+
const isWs = !isAtomicMarker(grapheme) && isWhitespaceChar(grapheme);
|
|
116
|
+
// Overflow check before advancing.
|
|
117
|
+
if (currentWidth + gWidth > maxWidth) {
|
|
118
|
+
if (wrapOppIndex >= 0 && currentWidth - wrapOppWidth + gWidth <= maxWidth) {
|
|
119
|
+
// Backtrack to last wrap opportunity (the remaining content
|
|
120
|
+
// plus the current grapheme still fits within maxWidth).
|
|
121
|
+
chunks.push({ text: line.slice(chunkStart, wrapOppIndex), startIndex: chunkStart, endIndex: wrapOppIndex });
|
|
122
|
+
chunkStart = wrapOppIndex;
|
|
123
|
+
currentWidth -= wrapOppWidth;
|
|
124
|
+
}
|
|
125
|
+
else if (chunkStart < charIndex) {
|
|
126
|
+
// No viable wrap opportunity: force-break at current position.
|
|
127
|
+
// This also handles the case where backtracking to a word
|
|
128
|
+
// boundary wouldn't help because the remaining content plus
|
|
129
|
+
// the current grapheme (e.g. a wide character) still exceeds
|
|
130
|
+
// maxWidth.
|
|
131
|
+
chunks.push({ text: line.slice(chunkStart, charIndex), startIndex: chunkStart, endIndex: charIndex });
|
|
132
|
+
chunkStart = charIndex;
|
|
133
|
+
currentWidth = 0;
|
|
134
|
+
}
|
|
135
|
+
wrapOppIndex = -1;
|
|
136
|
+
}
|
|
137
|
+
if (gWidth > maxWidth) {
|
|
138
|
+
// Single atomic segment wider than maxWidth (e.g. paste marker
|
|
139
|
+
// in a narrow terminal). Re-wrap it at grapheme granularity.
|
|
140
|
+
// The segment remains logically atomic for cursor
|
|
141
|
+
// movement / editing — the split is purely visual for word-wrap layout.
|
|
142
|
+
const subChunks = wordWrapLine(grapheme, maxWidth);
|
|
143
|
+
for (let j = 0; j < subChunks.length - 1; j++) {
|
|
144
|
+
const sc = subChunks[j];
|
|
145
|
+
chunks.push({ text: sc.text, startIndex: charIndex + sc.startIndex, endIndex: charIndex + sc.endIndex });
|
|
146
|
+
}
|
|
147
|
+
const last = subChunks[subChunks.length - 1];
|
|
148
|
+
chunkStart = charIndex + last.startIndex;
|
|
149
|
+
currentWidth = visibleWidth(last.text);
|
|
150
|
+
wrapOppIndex = -1;
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
// Advance.
|
|
154
|
+
currentWidth += gWidth;
|
|
155
|
+
// Record wrap opportunity: whitespace followed by non-whitespace.
|
|
156
|
+
// Multiple spaces join (no break between them); the break point is
|
|
157
|
+
// after the last space before the next word.
|
|
158
|
+
const next = segments[i + 1];
|
|
159
|
+
if (isWs && next && (isAtomicMarker(next.segment) || !isWhitespaceChar(next.segment))) {
|
|
160
|
+
wrapOppIndex = next.index;
|
|
161
|
+
wrapOppWidth = currentWidth;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
chunks.push({ text: line.slice(chunkStart), startIndex: chunkStart, endIndex: line.length });
|
|
165
|
+
return chunks;
|
|
166
|
+
}
|
|
167
|
+
const SLASH_COMMAND_SELECT_LIST_LAYOUT = {
|
|
168
|
+
minPrimaryColumnWidth: 12,
|
|
169
|
+
maxPrimaryColumnWidth: 32,
|
|
170
|
+
showItemMetadata: true,
|
|
171
|
+
showDirectionalScrollInfo: true,
|
|
172
|
+
showSelectedDescription: true,
|
|
173
|
+
};
|
|
174
|
+
const ATTACHMENT_AUTOCOMPLETE_DEBOUNCE_MS = 20;
|
|
175
|
+
let autocompleteAnchorId = 0;
|
|
176
|
+
export class Editor {
|
|
177
|
+
state = {
|
|
178
|
+
lines: [""],
|
|
179
|
+
cursorLine: 0,
|
|
180
|
+
cursorCol: 0,
|
|
181
|
+
};
|
|
182
|
+
focused = false;
|
|
183
|
+
tui;
|
|
184
|
+
theme;
|
|
185
|
+
paddingX = 0;
|
|
186
|
+
promptPrefix = "";
|
|
187
|
+
lastWidth = 80;
|
|
188
|
+
scrollOffset = 0;
|
|
189
|
+
borderColor;
|
|
190
|
+
backgroundColor;
|
|
191
|
+
autocompleteBackgroundColor;
|
|
192
|
+
commandColor;
|
|
193
|
+
autocompleteProvider;
|
|
194
|
+
autocompleteList;
|
|
195
|
+
autocompleteState = null;
|
|
196
|
+
autocompletePrefix = "";
|
|
197
|
+
autocompleteKind;
|
|
198
|
+
autocompleteMaxVisible = 5;
|
|
199
|
+
autocompleteAbort;
|
|
200
|
+
autocompleteDebounceTimer;
|
|
201
|
+
autocompleteRequestTask = Promise.resolve();
|
|
202
|
+
autocompleteStartToken = 0;
|
|
203
|
+
autocompleteRequestId = 0;
|
|
204
|
+
autocompleteOverlay;
|
|
205
|
+
autocompleteAnchorMarker = `\x1b_pi:autocomplete:${++autocompleteAnchorId}\x07`;
|
|
206
|
+
autocompleteOverlayComponent = {
|
|
207
|
+
render: (width) => this.renderAutocompleteOverlay(width),
|
|
208
|
+
invalidate: () => this.autocompleteList?.invalidate(),
|
|
209
|
+
};
|
|
210
|
+
pastes = new Map();
|
|
211
|
+
pasteCounter = 0;
|
|
212
|
+
pasteBuffer = "";
|
|
213
|
+
isInPaste = false;
|
|
214
|
+
history = [];
|
|
215
|
+
historyIndex = -1; // -1 = not browsing, 0 = most recent, 1 = older, etc.
|
|
216
|
+
killRing = new KillRing();
|
|
217
|
+
lastAction = null;
|
|
218
|
+
jumpMode = null;
|
|
219
|
+
preferredVisualCol = null;
|
|
220
|
+
// When the cursor is snapped to the start of an atomic segment, e.g. a
|
|
221
|
+
// paste marker, cursorCol no longer reflects where the cursor would have
|
|
222
|
+
// landed. This field stores the pre-snap cursorCol so that the next
|
|
223
|
+
// vertical move can resolve it to a visual column on whatever VL it belongs
|
|
224
|
+
// to.
|
|
225
|
+
snappedFromCursorCol = null;
|
|
226
|
+
undoStack = new UndoStack((snapshot) => ({
|
|
227
|
+
lines: [...snapshot.lines],
|
|
228
|
+
cursorLine: snapshot.cursorLine,
|
|
229
|
+
cursorCol: snapshot.cursorCol,
|
|
230
|
+
pastes: new Map(snapshot.pastes),
|
|
231
|
+
pasteCounter: snapshot.pasteCounter,
|
|
232
|
+
}));
|
|
233
|
+
onSubmit;
|
|
234
|
+
onChange;
|
|
235
|
+
disableSubmit = false;
|
|
236
|
+
constructor(tui, theme, options = {}) {
|
|
237
|
+
this.tui = tui;
|
|
238
|
+
this.theme = theme;
|
|
239
|
+
this.borderColor = theme.borderColor;
|
|
240
|
+
this.backgroundColor = theme.backgroundColor;
|
|
241
|
+
this.autocompleteBackgroundColor = theme.autocompleteBackgroundColor;
|
|
242
|
+
this.commandColor = theme.commandColor;
|
|
243
|
+
const paddingX = options.paddingX ?? 0;
|
|
244
|
+
this.paddingX = Number.isFinite(paddingX) ? Math.max(0, Math.floor(paddingX)) : 0;
|
|
245
|
+
this.promptPrefix = options.promptPrefix ?? "";
|
|
246
|
+
const maxVisible = options.autocompleteMaxVisible ?? 5;
|
|
247
|
+
this.autocompleteMaxVisible = Number.isFinite(maxVisible) ? Math.max(3, Math.min(20, Math.floor(maxVisible))) : 5;
|
|
248
|
+
}
|
|
249
|
+
/** Set of currently valid paste IDs, for marker-aware segmentation. */
|
|
250
|
+
validPasteIds() {
|
|
251
|
+
return new Set(this.pastes.keys());
|
|
252
|
+
}
|
|
253
|
+
/** Segment text with paste-marker awareness, only merging markers with valid IDs. */
|
|
254
|
+
segment(text) {
|
|
255
|
+
return segmentWithMarkers(text, this.validPasteIds());
|
|
256
|
+
}
|
|
257
|
+
getPaddingX() {
|
|
258
|
+
return this.paddingX;
|
|
259
|
+
}
|
|
260
|
+
setPaddingX(padding) {
|
|
261
|
+
const newPadding = Number.isFinite(padding) ? Math.max(0, Math.floor(padding)) : 0;
|
|
262
|
+
if (this.paddingX !== newPadding) {
|
|
263
|
+
this.paddingX = newPadding;
|
|
264
|
+
this.tui.requestRender();
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
getAutocompleteMaxVisible() {
|
|
268
|
+
return this.autocompleteMaxVisible;
|
|
269
|
+
}
|
|
270
|
+
setAutocompleteMaxVisible(maxVisible) {
|
|
271
|
+
const newMaxVisible = Number.isFinite(maxVisible) ? Math.max(3, Math.min(20, Math.floor(maxVisible))) : 5;
|
|
272
|
+
if (this.autocompleteMaxVisible !== newMaxVisible) {
|
|
273
|
+
this.autocompleteMaxVisible = newMaxVisible;
|
|
274
|
+
this.tui.requestRender();
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
getPromptPrefix() {
|
|
278
|
+
return this.promptPrefix;
|
|
279
|
+
}
|
|
280
|
+
formatPromptPrefix(prefix) {
|
|
281
|
+
return prefix;
|
|
282
|
+
}
|
|
283
|
+
getHiddenTextPrefixLength(_lineIndex, _line) {
|
|
284
|
+
return 0;
|
|
285
|
+
}
|
|
286
|
+
styleDisplayText(displayText, _layoutLineIndex, _lineText, _cursorCol) {
|
|
287
|
+
return displayText;
|
|
288
|
+
}
|
|
289
|
+
getLineHiddenTextPrefixLength(lineIndex, line) {
|
|
290
|
+
const hiddenLength = this.getHiddenTextPrefixLength(lineIndex, line);
|
|
291
|
+
if (!Number.isFinite(hiddenLength)) {
|
|
292
|
+
return 0;
|
|
293
|
+
}
|
|
294
|
+
return Math.max(0, Math.min(line.length, Math.floor(hiddenLength)));
|
|
295
|
+
}
|
|
296
|
+
setAutocompleteProvider(provider) {
|
|
297
|
+
this.cancelAutocomplete();
|
|
298
|
+
this.autocompleteProvider = provider;
|
|
299
|
+
}
|
|
300
|
+
/**
|
|
301
|
+
* Add a prompt to history for up/down arrow navigation.
|
|
302
|
+
* Called after successful submission.
|
|
303
|
+
*/
|
|
304
|
+
addToHistory(text) {
|
|
305
|
+
const trimmed = text.trim();
|
|
306
|
+
if (!trimmed)
|
|
307
|
+
return;
|
|
308
|
+
if (this.history.length > 0 && this.history[0] === trimmed)
|
|
309
|
+
return;
|
|
310
|
+
this.history.unshift(trimmed);
|
|
311
|
+
if (this.history.length > 100) {
|
|
312
|
+
this.history.pop();
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
/** Prompt history entries (most recent first). */
|
|
316
|
+
getHistory() {
|
|
317
|
+
return this.history;
|
|
318
|
+
}
|
|
319
|
+
/** Clear prompt history (e.g. when switching to a different session). */
|
|
320
|
+
clearHistory() {
|
|
321
|
+
this.history = [];
|
|
322
|
+
this.historyIndex = -1;
|
|
323
|
+
}
|
|
324
|
+
isEditorEmpty() {
|
|
325
|
+
return this.state.lines.length === 1 && this.state.lines[0] === "";
|
|
326
|
+
}
|
|
327
|
+
isOnFirstVisualLine() {
|
|
328
|
+
const visualLines = this.buildVisualLineMap(this.lastWidth);
|
|
329
|
+
const currentVisualLine = this.findCurrentVisualLine(visualLines);
|
|
330
|
+
return currentVisualLine === 0;
|
|
331
|
+
}
|
|
332
|
+
isOnLastVisualLine() {
|
|
333
|
+
const visualLines = this.buildVisualLineMap(this.lastWidth);
|
|
334
|
+
const currentVisualLine = this.findCurrentVisualLine(visualLines);
|
|
335
|
+
return currentVisualLine === visualLines.length - 1;
|
|
336
|
+
}
|
|
337
|
+
isHistoryNavigationActive() {
|
|
338
|
+
return this.historyIndex > -1;
|
|
339
|
+
}
|
|
340
|
+
navigateHistory(direction) {
|
|
341
|
+
this.lastAction = null;
|
|
342
|
+
if (this.history.length === 0)
|
|
343
|
+
return;
|
|
344
|
+
const newIndex = this.historyIndex - direction; // Up(-1) increases index, Down(1) decreases
|
|
345
|
+
if (newIndex < -1 || newIndex >= this.history.length)
|
|
346
|
+
return;
|
|
347
|
+
if (this.historyIndex === -1 && newIndex >= 0) {
|
|
348
|
+
this.pushUndoSnapshot();
|
|
349
|
+
}
|
|
350
|
+
this.historyIndex = newIndex;
|
|
351
|
+
if (this.historyIndex === -1) {
|
|
352
|
+
this.setTextInternal("");
|
|
353
|
+
}
|
|
354
|
+
else {
|
|
355
|
+
this.setTextInternal(this.history[this.historyIndex] || "");
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
/** Internal setText that doesn't reset history state - used by navigateHistory */
|
|
359
|
+
setTextInternal(text) {
|
|
360
|
+
const lines = text.split("\n");
|
|
361
|
+
this.state.lines = lines.length === 0 ? [""] : lines;
|
|
362
|
+
this.state.cursorLine = this.state.lines.length - 1;
|
|
363
|
+
this.setCursorCol(this.state.lines[this.state.cursorLine]?.length || 0);
|
|
364
|
+
this.scrollOffset = 0;
|
|
365
|
+
if (this.onChange) {
|
|
366
|
+
this.onChange(this.getText());
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
invalidate() { }
|
|
370
|
+
getRenderMetrics(width) {
|
|
371
|
+
const maxPadding = Math.max(0, Math.floor((width - 1) / 2));
|
|
372
|
+
const useBackgroundSurface = this.backgroundColor !== undefined;
|
|
373
|
+
const configuredPaddingX = Math.min(this.paddingX, maxPadding);
|
|
374
|
+
const paddingX = useBackgroundSurface
|
|
375
|
+
? Math.min(Math.max(configuredPaddingX, 2), maxPadding)
|
|
376
|
+
: configuredPaddingX;
|
|
377
|
+
const contentWidth = Math.max(1, width - paddingX * 2);
|
|
378
|
+
const promptPrefixText = this.getPromptPrefix();
|
|
379
|
+
const promptPrefixWidth = Math.min(visibleWidth(promptPrefixText), Math.max(0, contentWidth - 1));
|
|
380
|
+
return {
|
|
381
|
+
useBackgroundSurface,
|
|
382
|
+
paddingX,
|
|
383
|
+
promptPrefixText,
|
|
384
|
+
promptPrefixWidth,
|
|
385
|
+
inputWidth: Math.max(1, contentWidth - promptPrefixWidth),
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
getAutocompleteAnchorMarker() {
|
|
389
|
+
return this.autocompleteOverlay && this.focused ? this.autocompleteAnchorMarker : "";
|
|
390
|
+
}
|
|
391
|
+
render(width) {
|
|
392
|
+
const { useBackgroundSurface, paddingX, promptPrefixText, promptPrefixWidth, inputWidth } = this.getRenderMetrics(width);
|
|
393
|
+
const promptPrefix = promptPrefixWidth > 0 ? this.formatPromptPrefix(truncateToWidth(promptPrefixText, promptPrefixWidth, "")) : "";
|
|
394
|
+
// Layout width: with padding the cursor can overflow into it,
|
|
395
|
+
// without padding we reserve 1 column for the cursor.
|
|
396
|
+
const layoutWidth = Math.max(1, inputWidth - (paddingX ? 0 : 1));
|
|
397
|
+
this.lastWidth = layoutWidth;
|
|
398
|
+
const horizontal = this.borderColor("─");
|
|
399
|
+
const layoutLines = this.layoutText(layoutWidth);
|
|
400
|
+
const terminalRows = this.tui.terminal.rows;
|
|
401
|
+
const maxVisibleLines = Math.max(5, Math.floor(terminalRows * 0.3));
|
|
402
|
+
let cursorLineIndex = layoutLines.findIndex((line) => line.hasCursor);
|
|
403
|
+
if (cursorLineIndex === -1)
|
|
404
|
+
cursorLineIndex = 0;
|
|
405
|
+
if (cursorLineIndex < this.scrollOffset) {
|
|
406
|
+
this.scrollOffset = cursorLineIndex;
|
|
407
|
+
}
|
|
408
|
+
else if (cursorLineIndex >= this.scrollOffset + maxVisibleLines) {
|
|
409
|
+
this.scrollOffset = cursorLineIndex - maxVisibleLines + 1;
|
|
410
|
+
}
|
|
411
|
+
const maxScrollOffset = Math.max(0, layoutLines.length - maxVisibleLines);
|
|
412
|
+
this.scrollOffset = Math.max(0, Math.min(this.scrollOffset, maxScrollOffset));
|
|
413
|
+
const visibleLines = layoutLines.slice(this.scrollOffset, this.scrollOffset + maxVisibleLines);
|
|
414
|
+
const result = [];
|
|
415
|
+
const leftPadding = " ".repeat(paddingX);
|
|
416
|
+
const rightPadding = leftPadding;
|
|
417
|
+
const promptPrefixInset = promptPrefixWidth > 0 ? Math.min(1, paddingX) : 0;
|
|
418
|
+
const promptLeadingPadding = " ".repeat(promptPrefixInset);
|
|
419
|
+
const promptTrailingPadding = " ".repeat(Math.max(0, paddingX - promptPrefixInset));
|
|
420
|
+
const cursorReset = useBackgroundSurface ? "\x1b[27m" : "\x1b[0m";
|
|
421
|
+
const renderSurfaceLine = (line) => {
|
|
422
|
+
const padded = line + " ".repeat(Math.max(0, width - visibleWidth(line)));
|
|
423
|
+
return this.backgroundColor ? this.backgroundColor(padded) : padded;
|
|
424
|
+
};
|
|
425
|
+
if (!useBackgroundSurface) {
|
|
426
|
+
if (this.scrollOffset > 0) {
|
|
427
|
+
const indicator = `─── ↑ ${this.scrollOffset} more `;
|
|
428
|
+
const remaining = width - visibleWidth(indicator);
|
|
429
|
+
if (remaining >= 0) {
|
|
430
|
+
result.push(this.borderColor(indicator + "─".repeat(remaining)));
|
|
431
|
+
}
|
|
432
|
+
else {
|
|
433
|
+
result.push(this.borderColor(truncateToWidth(indicator, width)));
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
else {
|
|
437
|
+
result.push(horizontal.repeat(width));
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
else {
|
|
441
|
+
const line = this.scrollOffset > 0 ? this.borderColor(` ↑ ${this.scrollOffset} more`) : "";
|
|
442
|
+
result.push(renderSurfaceLine(truncateToWidth(line, width)));
|
|
443
|
+
}
|
|
444
|
+
// Emit hardware cursor marker only when focused and not showing autocomplete
|
|
445
|
+
const emitCursorMarker = this.focused && !this.autocompleteState;
|
|
446
|
+
for (let visibleLineIndex = 0; visibleLineIndex < visibleLines.length; visibleLineIndex++) {
|
|
447
|
+
const layoutLine = visibleLines[visibleLineIndex];
|
|
448
|
+
const absoluteLineIndex = this.scrollOffset + visibleLineIndex;
|
|
449
|
+
const linePromptPrefix = absoluteLineIndex === 0 ? promptPrefix : " ".repeat(promptPrefixWidth);
|
|
450
|
+
let displayText = layoutLine.text;
|
|
451
|
+
let lineVisibleWidth = visibleWidth(layoutLine.text);
|
|
452
|
+
let cursorInPadding = false;
|
|
453
|
+
if (layoutLine.hasCursor && layoutLine.cursorPos !== undefined) {
|
|
454
|
+
const before = displayText.slice(0, layoutLine.cursorPos);
|
|
455
|
+
const after = displayText.slice(layoutLine.cursorPos);
|
|
456
|
+
// Hardware cursor marker (zero-width, emitted before fake cursor for IME positioning)
|
|
457
|
+
const marker = emitCursorMarker ? CURSOR_MARKER : "";
|
|
458
|
+
if (after.length > 0) {
|
|
459
|
+
const afterGraphemes = [...this.segment(after)];
|
|
460
|
+
const firstGrapheme = afterGraphemes[0]?.segment || "";
|
|
461
|
+
const restAfter = after.slice(firstGrapheme.length);
|
|
462
|
+
const cursor = `\x1b[7m${firstGrapheme}${cursorReset}`;
|
|
463
|
+
displayText = before + marker + cursor + restAfter;
|
|
464
|
+
}
|
|
465
|
+
else {
|
|
466
|
+
const cursor = `\x1b[7m ${cursorReset}`;
|
|
467
|
+
displayText = before + marker + cursor;
|
|
468
|
+
lineVisibleWidth = lineVisibleWidth + 1;
|
|
469
|
+
if (lineVisibleWidth > inputWidth && paddingX > 0) {
|
|
470
|
+
cursorInPadding = true;
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
displayText = this.styleDisplayText(displayText, absoluteLineIndex, layoutLine.text, layoutLine.hasCursor ? layoutLine.cursorPos : undefined);
|
|
475
|
+
const padding = " ".repeat(Math.max(0, inputWidth - lineVisibleWidth));
|
|
476
|
+
const lineRightPadding = cursorInPadding ? rightPadding.slice(1) : rightPadding;
|
|
477
|
+
const contentLine = `${promptLeadingPadding}${linePromptPrefix}${promptTrailingPadding}${displayText}${padding}${lineRightPadding}`;
|
|
478
|
+
const anchorMarker = layoutLine.hasCursor ? this.getAutocompleteAnchorMarker() : "";
|
|
479
|
+
result.push(anchorMarker + (useBackgroundSurface ? renderSurfaceLine(contentLine) : contentLine));
|
|
480
|
+
}
|
|
481
|
+
// Render bottom border (with scroll indicator if more content below)
|
|
482
|
+
const linesBelow = layoutLines.length - (this.scrollOffset + visibleLines.length);
|
|
483
|
+
if (!useBackgroundSurface) {
|
|
484
|
+
if (linesBelow > 0) {
|
|
485
|
+
const indicator = `─── ↓ ${linesBelow} more `;
|
|
486
|
+
const remaining = width - visibleWidth(indicator);
|
|
487
|
+
result.push(this.borderColor(indicator + "─".repeat(Math.max(0, remaining))));
|
|
488
|
+
}
|
|
489
|
+
else {
|
|
490
|
+
result.push(horizontal.repeat(width));
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
else {
|
|
494
|
+
const line = linesBelow > 0 ? this.borderColor(` ↓ ${linesBelow} more`) : "";
|
|
495
|
+
result.push(renderSurfaceLine(truncateToWidth(line, width)));
|
|
496
|
+
}
|
|
497
|
+
return result;
|
|
498
|
+
}
|
|
499
|
+
renderAutocompleteOverlay(width) {
|
|
500
|
+
if (!(this.autocompleteState && this.autocompleteList))
|
|
501
|
+
return [];
|
|
502
|
+
const { useBackgroundSurface, paddingX, promptPrefixWidth, inputWidth } = this.getRenderMetrics(width);
|
|
503
|
+
const leftPadding = " ".repeat(paddingX + promptPrefixWidth);
|
|
504
|
+
const rightPadding = " ".repeat(paddingX);
|
|
505
|
+
const backgroundColor = this.autocompleteBackgroundColor ?? (useBackgroundSurface ? this.backgroundColor : undefined);
|
|
506
|
+
return ["", ...this.autocompleteList.render(inputWidth), ""].map((line) => {
|
|
507
|
+
const linePadding = " ".repeat(Math.max(0, inputWidth - visibleWidth(line)));
|
|
508
|
+
const contentLine = `${leftPadding}${line}${linePadding}${rightPadding}`;
|
|
509
|
+
return backgroundColor ? backgroundColor(contentLine) : contentLine;
|
|
510
|
+
});
|
|
511
|
+
}
|
|
512
|
+
handleInput(data) {
|
|
513
|
+
const kb = getKeybindings();
|
|
514
|
+
if (this.jumpMode !== null) {
|
|
515
|
+
if (kb.matches(data, "tui.editor.jumpForward") || kb.matches(data, "tui.editor.jumpBackward")) {
|
|
516
|
+
this.jumpMode = null;
|
|
517
|
+
return;
|
|
518
|
+
}
|
|
519
|
+
const printable = decodePrintableKey(data) ?? (data.charCodeAt(0) >= 32 ? data : undefined);
|
|
520
|
+
if (printable !== undefined) {
|
|
521
|
+
const direction = this.jumpMode;
|
|
522
|
+
this.jumpMode = null;
|
|
523
|
+
this.jumpToChar(printable, direction);
|
|
524
|
+
return;
|
|
525
|
+
}
|
|
526
|
+
this.jumpMode = null;
|
|
527
|
+
}
|
|
528
|
+
if (data.includes("\x1b[200~")) {
|
|
529
|
+
this.isInPaste = true;
|
|
530
|
+
this.pasteBuffer = "";
|
|
531
|
+
data = data.replace("\x1b[200~", "");
|
|
532
|
+
}
|
|
533
|
+
if (this.isInPaste) {
|
|
534
|
+
this.pasteBuffer += data;
|
|
535
|
+
const endIndex = this.pasteBuffer.indexOf("\x1b[201~");
|
|
536
|
+
if (endIndex !== -1) {
|
|
537
|
+
const pasteContent = this.pasteBuffer.substring(0, endIndex);
|
|
538
|
+
if (pasteContent.length > 0) {
|
|
539
|
+
this.handlePaste(pasteContent);
|
|
540
|
+
}
|
|
541
|
+
this.isInPaste = false;
|
|
542
|
+
const remaining = this.pasteBuffer.substring(endIndex + 6);
|
|
543
|
+
this.pasteBuffer = "";
|
|
544
|
+
if (remaining.length > 0) {
|
|
545
|
+
this.handleInput(remaining);
|
|
546
|
+
}
|
|
547
|
+
return;
|
|
548
|
+
}
|
|
549
|
+
return;
|
|
550
|
+
}
|
|
551
|
+
if (kb.matches(data, "tui.input.copy")) {
|
|
552
|
+
return;
|
|
553
|
+
}
|
|
554
|
+
if (kb.matches(data, "tui.editor.undo")) {
|
|
555
|
+
this.undo();
|
|
556
|
+
return;
|
|
557
|
+
}
|
|
558
|
+
if (this.autocompleteState && this.autocompleteList) {
|
|
559
|
+
if (kb.matches(data, "tui.select.cancel")) {
|
|
560
|
+
this.cancelAutocomplete();
|
|
561
|
+
return;
|
|
562
|
+
}
|
|
563
|
+
if (kb.matches(data, "tui.select.up") || kb.matches(data, "tui.select.down")) {
|
|
564
|
+
this.autocompleteList.handleInput(data);
|
|
565
|
+
return;
|
|
566
|
+
}
|
|
567
|
+
if (kb.matches(data, "tui.input.tab")) {
|
|
568
|
+
const selected = this.autocompleteList.getSelectedItem();
|
|
569
|
+
if (selected && this.autocompleteProvider) {
|
|
570
|
+
this.pushUndoSnapshot();
|
|
571
|
+
this.lastAction = null;
|
|
572
|
+
const result = this.autocompleteProvider.applyCompletion(this.state.lines, this.state.cursorLine, this.state.cursorCol, selected, this.autocompletePrefix);
|
|
573
|
+
this.state.lines = result.lines;
|
|
574
|
+
this.state.cursorLine = result.cursorLine;
|
|
575
|
+
this.setCursorCol(result.cursorCol);
|
|
576
|
+
this.cancelAutocomplete();
|
|
577
|
+
if (this.onChange)
|
|
578
|
+
this.onChange(this.getText());
|
|
579
|
+
}
|
|
580
|
+
return;
|
|
581
|
+
}
|
|
582
|
+
if (kb.matches(data, "tui.select.confirm")) {
|
|
583
|
+
const selected = this.autocompleteList.getSelectedItem();
|
|
584
|
+
if (selected && this.autocompleteProvider) {
|
|
585
|
+
const slashContext = this.getCurrentSlashCommandContext();
|
|
586
|
+
const isSlashCommandCompletion = this.autocompleteKind === "slash-command" ||
|
|
587
|
+
(this.autocompleteKind === undefined &&
|
|
588
|
+
this.autocompleteState === "regular" &&
|
|
589
|
+
this.autocompletePrefix.startsWith("/"));
|
|
590
|
+
const shouldSubmitSlashCommand = isSlashCommandCompletion && slashContext?.kind === "name" && slashContext.isAtPromptStart;
|
|
591
|
+
this.pushUndoSnapshot();
|
|
592
|
+
this.lastAction = null;
|
|
593
|
+
const result = this.autocompleteProvider.applyCompletion(this.state.lines, this.state.cursorLine, this.state.cursorCol, selected, this.autocompletePrefix);
|
|
594
|
+
this.state.lines = result.lines;
|
|
595
|
+
this.state.cursorLine = result.cursorLine;
|
|
596
|
+
this.setCursorCol(result.cursorCol);
|
|
597
|
+
if (isSlashCommandCompletion) {
|
|
598
|
+
this.cancelAutocomplete();
|
|
599
|
+
if (!shouldSubmitSlashCommand || selected.takesArgument) {
|
|
600
|
+
if (this.onChange)
|
|
601
|
+
this.onChange(this.getText());
|
|
602
|
+
return;
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
else {
|
|
606
|
+
this.cancelAutocomplete();
|
|
607
|
+
if (this.onChange)
|
|
608
|
+
this.onChange(this.getText());
|
|
609
|
+
return;
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
if (kb.matches(data, "tui.input.tab") && !this.autocompleteState) {
|
|
615
|
+
this.handleTabCompletion();
|
|
616
|
+
return;
|
|
617
|
+
}
|
|
618
|
+
if (kb.matches(data, "tui.editor.deleteToLineEnd")) {
|
|
619
|
+
this.deleteToEndOfLine();
|
|
620
|
+
return;
|
|
621
|
+
}
|
|
622
|
+
if (kb.matches(data, "tui.editor.deleteToLineStart")) {
|
|
623
|
+
this.deleteToStartOfLine();
|
|
624
|
+
return;
|
|
625
|
+
}
|
|
626
|
+
if (kb.matches(data, "tui.editor.deleteWordBackward")) {
|
|
627
|
+
this.deleteWordBackwards();
|
|
628
|
+
return;
|
|
629
|
+
}
|
|
630
|
+
if (kb.matches(data, "tui.editor.deleteWordForward")) {
|
|
631
|
+
this.deleteWordForward();
|
|
632
|
+
return;
|
|
633
|
+
}
|
|
634
|
+
if (kb.matches(data, "tui.editor.deleteCharBackward") || matchesKey(data, "shift+backspace")) {
|
|
635
|
+
this.handleBackspace();
|
|
636
|
+
return;
|
|
637
|
+
}
|
|
638
|
+
if (kb.matches(data, "tui.editor.deleteCharForward") || matchesKey(data, "shift+delete")) {
|
|
639
|
+
this.handleForwardDelete();
|
|
640
|
+
return;
|
|
641
|
+
}
|
|
642
|
+
if (kb.matches(data, "tui.editor.yank")) {
|
|
643
|
+
this.yank();
|
|
644
|
+
return;
|
|
645
|
+
}
|
|
646
|
+
if (kb.matches(data, "tui.editor.yankPop")) {
|
|
647
|
+
this.yankPop();
|
|
648
|
+
return;
|
|
649
|
+
}
|
|
650
|
+
if (kb.matches(data, "tui.editor.cursorLineStart")) {
|
|
651
|
+
this.moveToLineStart();
|
|
652
|
+
return;
|
|
653
|
+
}
|
|
654
|
+
if (kb.matches(data, "tui.editor.cursorLineEnd")) {
|
|
655
|
+
this.moveToLineEnd();
|
|
656
|
+
return;
|
|
657
|
+
}
|
|
658
|
+
if (kb.matches(data, "tui.editor.cursorWordLeft")) {
|
|
659
|
+
this.moveWordBackwards();
|
|
660
|
+
return;
|
|
661
|
+
}
|
|
662
|
+
if (kb.matches(data, "tui.editor.cursorWordRight")) {
|
|
663
|
+
this.moveWordForwards();
|
|
664
|
+
return;
|
|
665
|
+
}
|
|
666
|
+
if (kb.matches(data, "tui.input.newLine") ||
|
|
667
|
+
(data.charCodeAt(0) === 10 && data.length > 1) ||
|
|
668
|
+
data === "\x1b\r" ||
|
|
669
|
+
data === "\x1b[13;2~" ||
|
|
670
|
+
(data.length > 1 && data.includes("\x1b") && data.includes("\r")) ||
|
|
671
|
+
(data === "\n" && data.length === 1)) {
|
|
672
|
+
if (this.shouldSubmitOnBackslashEnter(data, kb)) {
|
|
673
|
+
this.handleBackspace();
|
|
674
|
+
this.submitValue();
|
|
675
|
+
return;
|
|
676
|
+
}
|
|
677
|
+
this.addNewLine();
|
|
678
|
+
return;
|
|
679
|
+
}
|
|
680
|
+
if (kb.matches(data, "tui.input.submit")) {
|
|
681
|
+
if (this.disableSubmit)
|
|
682
|
+
return;
|
|
683
|
+
// Workaround for terminals without Shift+Enter support:
|
|
684
|
+
// If char before cursor is \, delete it and insert newline instead of submitting.
|
|
685
|
+
const currentLine = this.state.lines[this.state.cursorLine] || "";
|
|
686
|
+
if (this.state.cursorCol > 0 && currentLine[this.state.cursorCol - 1] === "\\") {
|
|
687
|
+
this.handleBackspace();
|
|
688
|
+
this.addNewLine();
|
|
689
|
+
return;
|
|
690
|
+
}
|
|
691
|
+
this.submitValue();
|
|
692
|
+
return;
|
|
693
|
+
}
|
|
694
|
+
if (kb.matches(data, "tui.editor.cursorUp")) {
|
|
695
|
+
if (this.isEditorEmpty()) {
|
|
696
|
+
this.navigateHistory(-1);
|
|
697
|
+
}
|
|
698
|
+
else if (this.historyIndex > -1 && this.isOnFirstVisualLine()) {
|
|
699
|
+
this.navigateHistory(-1);
|
|
700
|
+
}
|
|
701
|
+
else if (this.isOnFirstVisualLine()) {
|
|
702
|
+
this.moveToLineStart();
|
|
703
|
+
}
|
|
704
|
+
else {
|
|
705
|
+
this.moveCursor(-1, 0);
|
|
706
|
+
}
|
|
707
|
+
return;
|
|
708
|
+
}
|
|
709
|
+
if (kb.matches(data, "tui.editor.cursorDown")) {
|
|
710
|
+
if (this.historyIndex > -1 && this.isOnLastVisualLine()) {
|
|
711
|
+
this.navigateHistory(1);
|
|
712
|
+
}
|
|
713
|
+
else if (this.isOnLastVisualLine()) {
|
|
714
|
+
this.moveToLineEnd();
|
|
715
|
+
}
|
|
716
|
+
else {
|
|
717
|
+
this.moveCursor(1, 0);
|
|
718
|
+
}
|
|
719
|
+
return;
|
|
720
|
+
}
|
|
721
|
+
if (kb.matches(data, "tui.editor.cursorRight")) {
|
|
722
|
+
this.moveCursor(0, 1);
|
|
723
|
+
return;
|
|
724
|
+
}
|
|
725
|
+
if (kb.matches(data, "tui.editor.cursorLeft")) {
|
|
726
|
+
this.moveCursor(0, -1);
|
|
727
|
+
return;
|
|
728
|
+
}
|
|
729
|
+
if (kb.matches(data, "tui.editor.pageUp")) {
|
|
730
|
+
this.pageScroll(-1);
|
|
731
|
+
return;
|
|
732
|
+
}
|
|
733
|
+
if (kb.matches(data, "tui.editor.pageDown")) {
|
|
734
|
+
this.pageScroll(1);
|
|
735
|
+
return;
|
|
736
|
+
}
|
|
737
|
+
if (kb.matches(data, "tui.editor.jumpForward")) {
|
|
738
|
+
this.jumpMode = "forward";
|
|
739
|
+
return;
|
|
740
|
+
}
|
|
741
|
+
if (kb.matches(data, "tui.editor.jumpBackward")) {
|
|
742
|
+
this.jumpMode = "backward";
|
|
743
|
+
return;
|
|
744
|
+
}
|
|
745
|
+
if (matchesKey(data, "shift+space")) {
|
|
746
|
+
this.insertCharacter(" ");
|
|
747
|
+
return;
|
|
748
|
+
}
|
|
749
|
+
const printable = decodePrintableKey(data);
|
|
750
|
+
if (printable !== undefined) {
|
|
751
|
+
this.insertCharacter(printable);
|
|
752
|
+
return;
|
|
753
|
+
}
|
|
754
|
+
if (data.charCodeAt(0) >= 32) {
|
|
755
|
+
this.insertCharacter(data);
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
layoutText(contentWidth) {
|
|
759
|
+
const layoutLines = [];
|
|
760
|
+
if (this.state.lines.length === 0 || (this.state.lines.length === 1 && this.state.lines[0] === "")) {
|
|
761
|
+
layoutLines.push({
|
|
762
|
+
text: "",
|
|
763
|
+
hasCursor: true,
|
|
764
|
+
cursorPos: 0,
|
|
765
|
+
});
|
|
766
|
+
return layoutLines;
|
|
767
|
+
}
|
|
768
|
+
for (let i = 0; i < this.state.lines.length; i++) {
|
|
769
|
+
const line = this.state.lines[i] || "";
|
|
770
|
+
const hiddenPrefixLength = this.getLineHiddenTextPrefixLength(i, line);
|
|
771
|
+
const displayLine = line.slice(hiddenPrefixLength);
|
|
772
|
+
const isCurrentLine = i === this.state.cursorLine;
|
|
773
|
+
const lineVisibleWidth = visibleWidth(displayLine);
|
|
774
|
+
if (displayLine.length === 0) {
|
|
775
|
+
layoutLines.push({
|
|
776
|
+
text: "",
|
|
777
|
+
hasCursor: isCurrentLine,
|
|
778
|
+
cursorPos: isCurrentLine ? 0 : undefined,
|
|
779
|
+
});
|
|
780
|
+
continue;
|
|
781
|
+
}
|
|
782
|
+
if (lineVisibleWidth <= contentWidth) {
|
|
783
|
+
if (isCurrentLine) {
|
|
784
|
+
layoutLines.push({
|
|
785
|
+
text: displayLine,
|
|
786
|
+
hasCursor: true,
|
|
787
|
+
cursorPos: Math.max(0, this.state.cursorCol - hiddenPrefixLength),
|
|
788
|
+
});
|
|
789
|
+
}
|
|
790
|
+
else {
|
|
791
|
+
layoutLines.push({
|
|
792
|
+
text: displayLine,
|
|
793
|
+
hasCursor: false,
|
|
794
|
+
});
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
else {
|
|
798
|
+
const chunks = wordWrapLine(displayLine, contentWidth, [...this.segment(displayLine)]);
|
|
799
|
+
for (let chunkIndex = 0; chunkIndex < chunks.length; chunkIndex++) {
|
|
800
|
+
const chunk = chunks[chunkIndex];
|
|
801
|
+
if (!chunk)
|
|
802
|
+
continue;
|
|
803
|
+
const cursorPos = Math.max(0, this.state.cursorCol - hiddenPrefixLength);
|
|
804
|
+
const isLastChunk = chunkIndex === chunks.length - 1;
|
|
805
|
+
// For word-wrapped chunks, we need to handle the case where
|
|
806
|
+
// cursor might be in trimmed whitespace at end of chunk
|
|
807
|
+
let hasCursorInChunk = false;
|
|
808
|
+
let adjustedCursorPos = 0;
|
|
809
|
+
if (isCurrentLine) {
|
|
810
|
+
if (isLastChunk) {
|
|
811
|
+
hasCursorInChunk = cursorPos >= chunk.startIndex;
|
|
812
|
+
adjustedCursorPos = cursorPos - chunk.startIndex;
|
|
813
|
+
}
|
|
814
|
+
else {
|
|
815
|
+
hasCursorInChunk = cursorPos >= chunk.startIndex && cursorPos < chunk.endIndex;
|
|
816
|
+
if (hasCursorInChunk) {
|
|
817
|
+
adjustedCursorPos = cursorPos - chunk.startIndex;
|
|
818
|
+
if (adjustedCursorPos > chunk.text.length) {
|
|
819
|
+
adjustedCursorPos = chunk.text.length;
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
if (hasCursorInChunk) {
|
|
825
|
+
layoutLines.push({
|
|
826
|
+
text: chunk.text,
|
|
827
|
+
hasCursor: true,
|
|
828
|
+
cursorPos: adjustedCursorPos,
|
|
829
|
+
});
|
|
830
|
+
}
|
|
831
|
+
else {
|
|
832
|
+
layoutLines.push({
|
|
833
|
+
text: chunk.text,
|
|
834
|
+
hasCursor: false,
|
|
835
|
+
});
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
return layoutLines;
|
|
841
|
+
}
|
|
842
|
+
getText() {
|
|
843
|
+
return this.state.lines.join("\n");
|
|
844
|
+
}
|
|
845
|
+
expandPasteMarkers(text) {
|
|
846
|
+
let result = text;
|
|
847
|
+
for (const [pasteId, pasteContent] of this.pastes) {
|
|
848
|
+
const markerRegex = new RegExp(`\\[paste #${pasteId}( (\\+\\d+ lines|\\d+ chars))?\\]`, "g");
|
|
849
|
+
result = result.replace(markerRegex, () => pasteContent);
|
|
850
|
+
}
|
|
851
|
+
return result;
|
|
852
|
+
}
|
|
853
|
+
/**
|
|
854
|
+
* Get text with paste markers expanded to their actual content.
|
|
855
|
+
* Use this when you need the full content (e.g., for external editor).
|
|
856
|
+
*/
|
|
857
|
+
getExpandedText() {
|
|
858
|
+
return this.expandPasteMarkers(this.state.lines.join("\n"));
|
|
859
|
+
}
|
|
860
|
+
getPasteSnapshot() {
|
|
861
|
+
return {
|
|
862
|
+
pastes: [...this.pastes],
|
|
863
|
+
pasteCounter: this.pasteCounter,
|
|
864
|
+
};
|
|
865
|
+
}
|
|
866
|
+
restorePasteSnapshot(snapshot) {
|
|
867
|
+
this.pastes = new Map(snapshot.pastes);
|
|
868
|
+
this.pasteCounter = snapshot.pasteCounter;
|
|
869
|
+
}
|
|
870
|
+
getLines() {
|
|
871
|
+
return [...this.state.lines];
|
|
872
|
+
}
|
|
873
|
+
getCursor() {
|
|
874
|
+
return { line: this.state.cursorLine, col: this.state.cursorCol };
|
|
875
|
+
}
|
|
876
|
+
setText(text) {
|
|
877
|
+
this.cancelAutocomplete();
|
|
878
|
+
this.lastAction = null;
|
|
879
|
+
this.historyIndex = -1; // Exit history browsing mode
|
|
880
|
+
const normalized = this.normalizeText(text);
|
|
881
|
+
if (this.getText() !== normalized) {
|
|
882
|
+
this.pushUndoSnapshot();
|
|
883
|
+
}
|
|
884
|
+
this.setTextInternal(normalized);
|
|
885
|
+
}
|
|
886
|
+
/**
|
|
887
|
+
* Insert text at the current cursor position.
|
|
888
|
+
* Used for programmatic insertion (e.g., clipboard image markers).
|
|
889
|
+
* This is atomic for undo - single undo restores entire pre-insert state.
|
|
890
|
+
*/
|
|
891
|
+
insertTextAtCursor(text) {
|
|
892
|
+
if (!text)
|
|
893
|
+
return;
|
|
894
|
+
this.cancelAutocomplete();
|
|
895
|
+
this.pushUndoSnapshot();
|
|
896
|
+
this.lastAction = null;
|
|
897
|
+
this.historyIndex = -1;
|
|
898
|
+
this.insertTextAtCursorInternal(text);
|
|
899
|
+
}
|
|
900
|
+
/**
|
|
901
|
+
* Normalize text for editor storage:
|
|
902
|
+
* - Normalize line endings (\r\n and \r -> \n)
|
|
903
|
+
* - Expand tabs to 4 spaces
|
|
904
|
+
*/
|
|
905
|
+
normalizeText(text) {
|
|
906
|
+
return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n").replace(/\t/g, " ");
|
|
907
|
+
}
|
|
908
|
+
/**
|
|
909
|
+
* Internal text insertion at cursor. Handles single and multi-line text.
|
|
910
|
+
* Does not push undo snapshots or trigger autocomplete - caller is responsible.
|
|
911
|
+
* Normalizes line endings and calls onChange once at the end.
|
|
912
|
+
*/
|
|
913
|
+
insertTextAtCursorInternal(text) {
|
|
914
|
+
if (!text)
|
|
915
|
+
return;
|
|
916
|
+
const normalized = this.normalizeText(text);
|
|
917
|
+
const insertedLines = normalized.split("\n");
|
|
918
|
+
const currentLine = this.state.lines[this.state.cursorLine] || "";
|
|
919
|
+
const beforeCursor = currentLine.slice(0, this.state.cursorCol);
|
|
920
|
+
const afterCursor = currentLine.slice(this.state.cursorCol);
|
|
921
|
+
if (insertedLines.length === 1) {
|
|
922
|
+
this.state.lines[this.state.cursorLine] = beforeCursor + normalized + afterCursor;
|
|
923
|
+
this.setCursorCol(this.state.cursorCol + normalized.length);
|
|
924
|
+
}
|
|
925
|
+
else {
|
|
926
|
+
this.state.lines = [
|
|
927
|
+
...this.state.lines.slice(0, this.state.cursorLine),
|
|
928
|
+
beforeCursor + insertedLines[0],
|
|
929
|
+
...insertedLines.slice(1, -1),
|
|
930
|
+
insertedLines[insertedLines.length - 1] + afterCursor,
|
|
931
|
+
...this.state.lines.slice(this.state.cursorLine + 1),
|
|
932
|
+
];
|
|
933
|
+
this.state.cursorLine += insertedLines.length - 1;
|
|
934
|
+
this.setCursorCol((insertedLines[insertedLines.length - 1] || "").length);
|
|
935
|
+
}
|
|
936
|
+
if (this.onChange) {
|
|
937
|
+
this.onChange(this.getText());
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
insertCharacter(char, skipUndoCoalescing) {
|
|
941
|
+
this.historyIndex = -1; // Exit history browsing mode
|
|
942
|
+
// Undo coalescing (fish-style):
|
|
943
|
+
// - Consecutive word chars coalesce into one undo unit
|
|
944
|
+
// - Space captures state before itself (so undo removes space+following word together)
|
|
945
|
+
// - Each space is separately undoable
|
|
946
|
+
// Skip coalescing when called from atomic operations (e.g., handlePaste)
|
|
947
|
+
if (!skipUndoCoalescing) {
|
|
948
|
+
if (isWhitespaceChar(char) || this.lastAction !== "type-word") {
|
|
949
|
+
this.pushUndoSnapshot();
|
|
950
|
+
}
|
|
951
|
+
this.lastAction = "type-word";
|
|
952
|
+
}
|
|
953
|
+
const line = this.state.lines[this.state.cursorLine] || "";
|
|
954
|
+
const before = line.slice(0, this.state.cursorCol);
|
|
955
|
+
const after = line.slice(this.state.cursorCol);
|
|
956
|
+
this.state.lines[this.state.cursorLine] = before + char + after;
|
|
957
|
+
this.setCursorCol(this.state.cursorCol + char.length);
|
|
958
|
+
if (this.onChange) {
|
|
959
|
+
this.onChange(this.getText());
|
|
960
|
+
}
|
|
961
|
+
if (!this.autocompleteState) {
|
|
962
|
+
const slashContext = this.getCurrentSlashCommandContext();
|
|
963
|
+
if (char === "/" && slashContext?.kind === "name") {
|
|
964
|
+
this.tryTriggerAutocomplete();
|
|
965
|
+
}
|
|
966
|
+
else if (char === "@" || char === "#") {
|
|
967
|
+
const currentLine = this.state.lines[this.state.cursorLine] || "";
|
|
968
|
+
const textBeforeCursor = currentLine.slice(0, this.state.cursorCol);
|
|
969
|
+
const charBeforeSymbol = textBeforeCursor[textBeforeCursor.length - 2];
|
|
970
|
+
if (textBeforeCursor.length === 1 || charBeforeSymbol === " " || charBeforeSymbol === "\t") {
|
|
971
|
+
this.tryTriggerAutocomplete();
|
|
972
|
+
}
|
|
973
|
+
}
|
|
974
|
+
else if (/[a-zA-Z0-9.\-_]/.test(char)) {
|
|
975
|
+
const currentLine = this.state.lines[this.state.cursorLine] || "";
|
|
976
|
+
const textBeforeCursor = currentLine.slice(0, this.state.cursorCol);
|
|
977
|
+
if (slashContext) {
|
|
978
|
+
this.tryTriggerAutocomplete();
|
|
979
|
+
}
|
|
980
|
+
else if (textBeforeCursor.match(/(?:^|[\s])[@#][^\s]*$/)) {
|
|
981
|
+
this.tryTriggerAutocomplete();
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
else {
|
|
986
|
+
this.refreshAutocompleteAfterEdit();
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
handlePaste(pastedText) {
|
|
990
|
+
this.cancelAutocomplete();
|
|
991
|
+
this.historyIndex = -1; // Exit history browsing mode
|
|
992
|
+
this.lastAction = null;
|
|
993
|
+
this.pushUndoSnapshot();
|
|
994
|
+
// Some terminals (e.g. tmux popups with extended-keys-format=csi-u) re-encode
|
|
995
|
+
// control bytes inside bracketed paste as CSI-u Ctrl+<letter> sequences
|
|
996
|
+
// (ESC [ <codepoint> ; 5 u). Decode those back to their literal byte so the
|
|
997
|
+
// per-char filter below preserves newlines instead of stripping ESC and
|
|
998
|
+
// leaking the printable tail (e.g. "[106;5u") into the editor.
|
|
999
|
+
const decodedText = pastedText.replace(/\x1b\[(\d+);5u/g, (match, code) => {
|
|
1000
|
+
const cp = Number(code);
|
|
1001
|
+
if (cp >= 97 && cp <= 122)
|
|
1002
|
+
return String.fromCharCode(cp - 96);
|
|
1003
|
+
if (cp >= 65 && cp <= 90)
|
|
1004
|
+
return String.fromCharCode(cp - 64);
|
|
1005
|
+
return match;
|
|
1006
|
+
});
|
|
1007
|
+
const cleanText = this.normalizeText(decodedText);
|
|
1008
|
+
let filteredText = cleanText
|
|
1009
|
+
.split("")
|
|
1010
|
+
.filter((char) => char === "\n" || char.charCodeAt(0) >= 32)
|
|
1011
|
+
.join("");
|
|
1012
|
+
// If pasting a file path (starts with /, ~, or .) and the character before
|
|
1013
|
+
// the cursor is a word character, prepend a space for better readability
|
|
1014
|
+
if (/^[/~.]/.test(filteredText)) {
|
|
1015
|
+
const currentLine = this.state.lines[this.state.cursorLine] || "";
|
|
1016
|
+
const charBeforeCursor = this.state.cursorCol > 0 ? currentLine[this.state.cursorCol - 1] : "";
|
|
1017
|
+
if (charBeforeCursor && /\w/.test(charBeforeCursor)) {
|
|
1018
|
+
filteredText = ` ${filteredText}`;
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
// Split into lines to check for large paste
|
|
1022
|
+
const pastedLines = filteredText.split("\n");
|
|
1023
|
+
// Check if this is a large paste (> 10 lines or > 1000 characters)
|
|
1024
|
+
const totalChars = filteredText.length;
|
|
1025
|
+
if (pastedLines.length > 10 || totalChars > 1000) {
|
|
1026
|
+
this.pasteCounter++;
|
|
1027
|
+
const pasteId = this.pasteCounter;
|
|
1028
|
+
this.pastes.set(pasteId, filteredText);
|
|
1029
|
+
const marker = pastedLines.length > 10
|
|
1030
|
+
? `[paste #${pasteId} +${pastedLines.length} lines]`
|
|
1031
|
+
: `[paste #${pasteId} ${totalChars} chars]`;
|
|
1032
|
+
this.insertTextAtCursorInternal(marker);
|
|
1033
|
+
return;
|
|
1034
|
+
}
|
|
1035
|
+
if (pastedLines.length === 1) {
|
|
1036
|
+
this.insertTextAtCursorInternal(filteredText);
|
|
1037
|
+
return;
|
|
1038
|
+
}
|
|
1039
|
+
// Multi-line paste - use direct state manipulation
|
|
1040
|
+
this.insertTextAtCursorInternal(filteredText);
|
|
1041
|
+
}
|
|
1042
|
+
addNewLine() {
|
|
1043
|
+
this.cancelAutocomplete();
|
|
1044
|
+
this.historyIndex = -1; // Exit history browsing mode
|
|
1045
|
+
this.lastAction = null;
|
|
1046
|
+
this.pushUndoSnapshot();
|
|
1047
|
+
const currentLine = this.state.lines[this.state.cursorLine] || "";
|
|
1048
|
+
const before = currentLine.slice(0, this.state.cursorCol);
|
|
1049
|
+
const after = currentLine.slice(this.state.cursorCol);
|
|
1050
|
+
this.state.lines[this.state.cursorLine] = before;
|
|
1051
|
+
this.state.lines.splice(this.state.cursorLine + 1, 0, after);
|
|
1052
|
+
this.state.cursorLine++;
|
|
1053
|
+
this.setCursorCol(0);
|
|
1054
|
+
if (this.onChange) {
|
|
1055
|
+
this.onChange(this.getText());
|
|
1056
|
+
}
|
|
1057
|
+
}
|
|
1058
|
+
shouldSubmitOnBackslashEnter(data, kb) {
|
|
1059
|
+
if (this.disableSubmit)
|
|
1060
|
+
return false;
|
|
1061
|
+
if (!matchesKey(data, "enter"))
|
|
1062
|
+
return false;
|
|
1063
|
+
const submitKeys = kb.getKeys("tui.input.submit");
|
|
1064
|
+
const hasShiftEnter = submitKeys.includes("shift+enter") || submitKeys.includes("shift+return");
|
|
1065
|
+
if (!hasShiftEnter)
|
|
1066
|
+
return false;
|
|
1067
|
+
const currentLine = this.state.lines[this.state.cursorLine] || "";
|
|
1068
|
+
return this.state.cursorCol > 0 && currentLine[this.state.cursorCol - 1] === "\\";
|
|
1069
|
+
}
|
|
1070
|
+
submitValue() {
|
|
1071
|
+
this.cancelAutocomplete();
|
|
1072
|
+
const result = this.expandPasteMarkers(this.state.lines.join("\n")).trim();
|
|
1073
|
+
this.state = { lines: [""], cursorLine: 0, cursorCol: 0 };
|
|
1074
|
+
this.pastes.clear();
|
|
1075
|
+
this.pasteCounter = 0;
|
|
1076
|
+
this.historyIndex = -1;
|
|
1077
|
+
this.scrollOffset = 0;
|
|
1078
|
+
this.undoStack.clear();
|
|
1079
|
+
this.lastAction = null;
|
|
1080
|
+
if (this.onChange)
|
|
1081
|
+
this.onChange("");
|
|
1082
|
+
if (this.onSubmit)
|
|
1083
|
+
this.onSubmit(result);
|
|
1084
|
+
}
|
|
1085
|
+
handleBackspace() {
|
|
1086
|
+
this.historyIndex = -1; // Exit history browsing mode
|
|
1087
|
+
this.lastAction = null;
|
|
1088
|
+
const line = this.state.lines[this.state.cursorLine] || "";
|
|
1089
|
+
const lineStartCol = this.getLineHiddenTextPrefixLength(this.state.cursorLine, line);
|
|
1090
|
+
if (this.state.cursorCol > lineStartCol) {
|
|
1091
|
+
this.pushUndoSnapshot();
|
|
1092
|
+
const beforeCursor = line.slice(lineStartCol, this.state.cursorCol);
|
|
1093
|
+
const graphemes = [...this.segment(beforeCursor)];
|
|
1094
|
+
const lastGrapheme = graphemes[graphemes.length - 1];
|
|
1095
|
+
const graphemeLength = lastGrapheme ? lastGrapheme.segment.length : 1;
|
|
1096
|
+
const before = line.slice(0, this.state.cursorCol - graphemeLength);
|
|
1097
|
+
const after = line.slice(this.state.cursorCol);
|
|
1098
|
+
this.state.lines[this.state.cursorLine] = before + after;
|
|
1099
|
+
this.setCursorCol(this.state.cursorCol - graphemeLength);
|
|
1100
|
+
}
|
|
1101
|
+
else if (lineStartCol > 0 && line.length === lineStartCol) {
|
|
1102
|
+
this.pushUndoSnapshot();
|
|
1103
|
+
this.state.lines[this.state.cursorLine] = "";
|
|
1104
|
+
this.setCursorCol(0);
|
|
1105
|
+
}
|
|
1106
|
+
else if (this.state.cursorLine > 0) {
|
|
1107
|
+
this.pushUndoSnapshot();
|
|
1108
|
+
const currentLine = this.state.lines[this.state.cursorLine] || "";
|
|
1109
|
+
const previousLine = this.state.lines[this.state.cursorLine - 1] || "";
|
|
1110
|
+
this.state.lines[this.state.cursorLine - 1] = previousLine + currentLine;
|
|
1111
|
+
this.state.lines.splice(this.state.cursorLine, 1);
|
|
1112
|
+
this.state.cursorLine--;
|
|
1113
|
+
this.setCursorCol(previousLine.length);
|
|
1114
|
+
}
|
|
1115
|
+
if (this.onChange) {
|
|
1116
|
+
this.onChange(this.getText());
|
|
1117
|
+
}
|
|
1118
|
+
this.refreshAutocompleteAfterEdit(true);
|
|
1119
|
+
}
|
|
1120
|
+
/**
|
|
1121
|
+
* Set cursor column and clear preferredVisualCol.
|
|
1122
|
+
* Use this for all non-vertical cursor movements to reset sticky column behavior.
|
|
1123
|
+
*/
|
|
1124
|
+
setCursorCol(col) {
|
|
1125
|
+
this.state.cursorCol = col;
|
|
1126
|
+
this.preferredVisualCol = null;
|
|
1127
|
+
this.snappedFromCursorCol = null;
|
|
1128
|
+
}
|
|
1129
|
+
/**
|
|
1130
|
+
* Move cursor to a target visual line, applying sticky column logic.
|
|
1131
|
+
* Shared by moveCursor() and pageScroll().
|
|
1132
|
+
*/
|
|
1133
|
+
moveToVisualLine(visualLines, currentVisualLine, targetVisualLine) {
|
|
1134
|
+
const currentVL = visualLines[currentVisualLine];
|
|
1135
|
+
const targetVL = visualLines[targetVisualLine];
|
|
1136
|
+
if (!(currentVL && targetVL))
|
|
1137
|
+
return;
|
|
1138
|
+
// When the cursor was snapped to a segment start, resolve the pre-snap
|
|
1139
|
+
// position against the VL it belongs to. This gives the correct visual
|
|
1140
|
+
// column even after a resize reshuffles VLs.
|
|
1141
|
+
let currentVisualCol;
|
|
1142
|
+
if (this.snappedFromCursorCol !== null) {
|
|
1143
|
+
const vlIndex = this.findVisualLineAt(visualLines, currentVL.logicalLine, this.snappedFromCursorCol);
|
|
1144
|
+
currentVisualCol = this.snappedFromCursorCol - visualLines[vlIndex].startCol;
|
|
1145
|
+
}
|
|
1146
|
+
else {
|
|
1147
|
+
currentVisualCol = this.state.cursorCol - currentVL.startCol;
|
|
1148
|
+
}
|
|
1149
|
+
// For non-last segments, clamp to length-1 to stay within the segment
|
|
1150
|
+
const isLastSourceSegment = currentVisualLine === visualLines.length - 1 ||
|
|
1151
|
+
visualLines[currentVisualLine + 1]?.logicalLine !== currentVL.logicalLine;
|
|
1152
|
+
const sourceMaxVisualCol = isLastSourceSegment ? currentVL.length : Math.max(0, currentVL.length - 1);
|
|
1153
|
+
const isLastTargetSegment = targetVisualLine === visualLines.length - 1 ||
|
|
1154
|
+
visualLines[targetVisualLine + 1]?.logicalLine !== targetVL.logicalLine;
|
|
1155
|
+
const targetMaxVisualCol = isLastTargetSegment ? targetVL.length : Math.max(0, targetVL.length - 1);
|
|
1156
|
+
const moveToVisualCol = this.computeVerticalMoveColumn(currentVisualCol, sourceMaxVisualCol, targetMaxVisualCol);
|
|
1157
|
+
this.state.cursorLine = targetVL.logicalLine;
|
|
1158
|
+
const targetCol = targetVL.startCol + moveToVisualCol;
|
|
1159
|
+
const logicalLine = this.state.lines[targetVL.logicalLine] || "";
|
|
1160
|
+
this.state.cursorCol = Math.min(targetCol, logicalLine.length);
|
|
1161
|
+
// Snap cursor to atomic segment boundary (e.g. paste markers)
|
|
1162
|
+
// so the cursor never lands in the middle of a multi-grapheme unit.
|
|
1163
|
+
// Single-grapheme segments don't need snapping.
|
|
1164
|
+
const segments = [...this.segment(logicalLine)];
|
|
1165
|
+
for (const seg of segments) {
|
|
1166
|
+
if (seg.index > this.state.cursorCol)
|
|
1167
|
+
break;
|
|
1168
|
+
if (seg.segment.length <= 1)
|
|
1169
|
+
continue;
|
|
1170
|
+
if (this.state.cursorCol < seg.index + seg.segment.length) {
|
|
1171
|
+
const isContinuation = seg.index < targetVL.startCol;
|
|
1172
|
+
const isMovingDown = targetVisualLine > currentVisualLine;
|
|
1173
|
+
if (isContinuation && isMovingDown) {
|
|
1174
|
+
// The segment started on a previous visual line, and we
|
|
1175
|
+
// already visited it on the way down. Skip all remaining
|
|
1176
|
+
// continuation VLs and land on the first VL past it.
|
|
1177
|
+
const segEnd = seg.index + seg.segment.length;
|
|
1178
|
+
let next = targetVisualLine + 1;
|
|
1179
|
+
while (next < visualLines.length &&
|
|
1180
|
+
visualLines[next].logicalLine === targetVL.logicalLine &&
|
|
1181
|
+
visualLines[next].startCol < segEnd) {
|
|
1182
|
+
next++;
|
|
1183
|
+
}
|
|
1184
|
+
if (next < visualLines.length) {
|
|
1185
|
+
this.moveToVisualLine(visualLines, currentVisualLine, next);
|
|
1186
|
+
return;
|
|
1187
|
+
}
|
|
1188
|
+
}
|
|
1189
|
+
// Snap to the start of the segment so it gets highlighted.
|
|
1190
|
+
// Store the pre-snap position so the next vertical move can
|
|
1191
|
+
// resolve it to the correct visual column.
|
|
1192
|
+
this.snappedFromCursorCol = this.state.cursorCol;
|
|
1193
|
+
this.state.cursorCol = seg.index;
|
|
1194
|
+
return;
|
|
1195
|
+
}
|
|
1196
|
+
}
|
|
1197
|
+
// No snap occurred – we moved out of the atomic segment.
|
|
1198
|
+
this.snappedFromCursorCol = null;
|
|
1199
|
+
}
|
|
1200
|
+
/**
|
|
1201
|
+
* Compute the target visual column for vertical cursor movement.
|
|
1202
|
+
* Implements the sticky column decision table:
|
|
1203
|
+
*
|
|
1204
|
+
* | P | S | T | U | Scenario | Set Preferred | Move To |
|
|
1205
|
+
* |---|---|---|---| ---------------------------------------------------- |---------------|-------------|
|
|
1206
|
+
* | 0 | * | 0 | - | Start nav, target fits | null | current |
|
|
1207
|
+
* | 0 | * | 1 | - | Start nav, target shorter | current | target end |
|
|
1208
|
+
* | 1 | 0 | 0 | 0 | Clamped, target fits preferred | null | preferred |
|
|
1209
|
+
* | 1 | 0 | 0 | 1 | Clamped, target longer but still can't fit preferred | keep | target end |
|
|
1210
|
+
* | 1 | 0 | 1 | - | Clamped, target even shorter | keep | target end |
|
|
1211
|
+
* | 1 | 1 | 0 | - | Rewrapped, target fits current | null | current |
|
|
1212
|
+
* | 1 | 1 | 1 | - | Rewrapped, target shorter than current | current | target end |
|
|
1213
|
+
*
|
|
1214
|
+
* Where:
|
|
1215
|
+
* - P = preferred col is set
|
|
1216
|
+
* - S = cursor in middle of source line (not clamped to end)
|
|
1217
|
+
* - T = target line shorter than current visual col
|
|
1218
|
+
* - U = target line shorter than preferred col
|
|
1219
|
+
*/
|
|
1220
|
+
computeVerticalMoveColumn(currentVisualCol, sourceMaxVisualCol, targetMaxVisualCol) {
|
|
1221
|
+
const hasPreferred = this.preferredVisualCol !== null; // P
|
|
1222
|
+
const cursorInMiddle = currentVisualCol < sourceMaxVisualCol; // S
|
|
1223
|
+
const targetTooShort = targetMaxVisualCol < currentVisualCol; // T
|
|
1224
|
+
if (!hasPreferred || cursorInMiddle) {
|
|
1225
|
+
if (targetTooShort) {
|
|
1226
|
+
// Cases 2 and 7
|
|
1227
|
+
this.preferredVisualCol = currentVisualCol;
|
|
1228
|
+
return targetMaxVisualCol;
|
|
1229
|
+
}
|
|
1230
|
+
// Cases 1 and 6
|
|
1231
|
+
this.preferredVisualCol = null;
|
|
1232
|
+
return currentVisualCol;
|
|
1233
|
+
}
|
|
1234
|
+
const targetCantFitPreferred = targetMaxVisualCol < this.preferredVisualCol; // U
|
|
1235
|
+
if (targetTooShort || targetCantFitPreferred) {
|
|
1236
|
+
// Cases 4 and 5
|
|
1237
|
+
return targetMaxVisualCol;
|
|
1238
|
+
}
|
|
1239
|
+
// Case 3
|
|
1240
|
+
const result = this.preferredVisualCol;
|
|
1241
|
+
this.preferredVisualCol = null;
|
|
1242
|
+
return result;
|
|
1243
|
+
}
|
|
1244
|
+
moveToLineStart() {
|
|
1245
|
+
this.lastAction = null;
|
|
1246
|
+
const currentLine = this.state.lines[this.state.cursorLine] || "";
|
|
1247
|
+
this.setCursorCol(this.getLineHiddenTextPrefixLength(this.state.cursorLine, currentLine));
|
|
1248
|
+
}
|
|
1249
|
+
moveToLineEnd() {
|
|
1250
|
+
this.lastAction = null;
|
|
1251
|
+
const currentLine = this.state.lines[this.state.cursorLine] || "";
|
|
1252
|
+
this.setCursorCol(currentLine.length);
|
|
1253
|
+
}
|
|
1254
|
+
deleteToStartOfLine() {
|
|
1255
|
+
this.historyIndex = -1; // Exit history browsing mode
|
|
1256
|
+
const currentLine = this.state.lines[this.state.cursorLine] || "";
|
|
1257
|
+
const lineStartCol = this.getLineHiddenTextPrefixLength(this.state.cursorLine, currentLine);
|
|
1258
|
+
if (this.state.cursorCol > lineStartCol) {
|
|
1259
|
+
this.pushUndoSnapshot();
|
|
1260
|
+
// Calculate text to be deleted and save to kill ring (backward deletion = prepend)
|
|
1261
|
+
const deletedText = currentLine.slice(lineStartCol, this.state.cursorCol);
|
|
1262
|
+
this.killRing.push(deletedText, { prepend: true, accumulate: this.lastAction === "kill" });
|
|
1263
|
+
this.lastAction = "kill";
|
|
1264
|
+
this.state.lines[this.state.cursorLine] =
|
|
1265
|
+
currentLine.slice(0, lineStartCol) + currentLine.slice(this.state.cursorCol);
|
|
1266
|
+
this.setCursorCol(lineStartCol);
|
|
1267
|
+
}
|
|
1268
|
+
else if (this.state.cursorLine > 0) {
|
|
1269
|
+
this.pushUndoSnapshot();
|
|
1270
|
+
// At start of line - merge with previous line, treating newline as deleted text
|
|
1271
|
+
this.killRing.push("\n", { prepend: true, accumulate: this.lastAction === "kill" });
|
|
1272
|
+
this.lastAction = "kill";
|
|
1273
|
+
const previousLine = this.state.lines[this.state.cursorLine - 1] || "";
|
|
1274
|
+
this.state.lines[this.state.cursorLine - 1] = previousLine + currentLine;
|
|
1275
|
+
this.state.lines.splice(this.state.cursorLine, 1);
|
|
1276
|
+
this.state.cursorLine--;
|
|
1277
|
+
this.setCursorCol(previousLine.length);
|
|
1278
|
+
}
|
|
1279
|
+
if (this.onChange) {
|
|
1280
|
+
this.onChange(this.getText());
|
|
1281
|
+
}
|
|
1282
|
+
this.refreshAutocompleteAfterEdit();
|
|
1283
|
+
}
|
|
1284
|
+
deleteToEndOfLine() {
|
|
1285
|
+
this.historyIndex = -1; // Exit history browsing mode
|
|
1286
|
+
const currentLine = this.state.lines[this.state.cursorLine] || "";
|
|
1287
|
+
if (this.state.cursorCol < currentLine.length) {
|
|
1288
|
+
this.pushUndoSnapshot();
|
|
1289
|
+
// Calculate text to be deleted and save to kill ring (forward deletion = append)
|
|
1290
|
+
const deletedText = currentLine.slice(this.state.cursorCol);
|
|
1291
|
+
this.killRing.push(deletedText, { prepend: false, accumulate: this.lastAction === "kill" });
|
|
1292
|
+
this.lastAction = "kill";
|
|
1293
|
+
this.state.lines[this.state.cursorLine] = currentLine.slice(0, this.state.cursorCol);
|
|
1294
|
+
}
|
|
1295
|
+
else if (this.state.cursorLine < this.state.lines.length - 1) {
|
|
1296
|
+
this.pushUndoSnapshot();
|
|
1297
|
+
// At end of line - merge with next line, treating newline as deleted text
|
|
1298
|
+
this.killRing.push("\n", { prepend: false, accumulate: this.lastAction === "kill" });
|
|
1299
|
+
this.lastAction = "kill";
|
|
1300
|
+
const nextLine = this.state.lines[this.state.cursorLine + 1] || "";
|
|
1301
|
+
this.state.lines[this.state.cursorLine] = currentLine + nextLine;
|
|
1302
|
+
this.state.lines.splice(this.state.cursorLine + 1, 1);
|
|
1303
|
+
}
|
|
1304
|
+
if (this.onChange) {
|
|
1305
|
+
this.onChange(this.getText());
|
|
1306
|
+
}
|
|
1307
|
+
this.refreshAutocompleteAfterEdit();
|
|
1308
|
+
}
|
|
1309
|
+
deleteWordBackwards() {
|
|
1310
|
+
this.historyIndex = -1; // Exit history browsing mode
|
|
1311
|
+
const currentLine = this.state.lines[this.state.cursorLine] || "";
|
|
1312
|
+
// If at start of line, behave like backspace at column 0 (merge with previous line)
|
|
1313
|
+
if (this.state.cursorCol === 0) {
|
|
1314
|
+
if (this.state.cursorLine > 0) {
|
|
1315
|
+
this.pushUndoSnapshot();
|
|
1316
|
+
// Treat newline as deleted text (backward deletion = prepend)
|
|
1317
|
+
this.killRing.push("\n", { prepend: true, accumulate: this.lastAction === "kill" });
|
|
1318
|
+
this.lastAction = "kill";
|
|
1319
|
+
const previousLine = this.state.lines[this.state.cursorLine - 1] || "";
|
|
1320
|
+
this.state.lines[this.state.cursorLine - 1] = previousLine + currentLine;
|
|
1321
|
+
this.state.lines.splice(this.state.cursorLine, 1);
|
|
1322
|
+
this.state.cursorLine--;
|
|
1323
|
+
this.setCursorCol(previousLine.length);
|
|
1324
|
+
}
|
|
1325
|
+
}
|
|
1326
|
+
else {
|
|
1327
|
+
this.pushUndoSnapshot();
|
|
1328
|
+
// Save lastAction before cursor movement (moveWordBackwards resets it)
|
|
1329
|
+
const wasKill = this.lastAction === "kill";
|
|
1330
|
+
const oldCursorCol = this.state.cursorCol;
|
|
1331
|
+
this.moveWordBackwards();
|
|
1332
|
+
const deleteFrom = this.state.cursorCol;
|
|
1333
|
+
this.setCursorCol(oldCursorCol);
|
|
1334
|
+
const deletedText = currentLine.slice(deleteFrom, this.state.cursorCol);
|
|
1335
|
+
this.killRing.push(deletedText, { prepend: true, accumulate: wasKill });
|
|
1336
|
+
this.lastAction = "kill";
|
|
1337
|
+
this.state.lines[this.state.cursorLine] =
|
|
1338
|
+
currentLine.slice(0, deleteFrom) + currentLine.slice(this.state.cursorCol);
|
|
1339
|
+
this.setCursorCol(deleteFrom);
|
|
1340
|
+
}
|
|
1341
|
+
if (this.onChange) {
|
|
1342
|
+
this.onChange(this.getText());
|
|
1343
|
+
}
|
|
1344
|
+
this.refreshAutocompleteAfterEdit();
|
|
1345
|
+
}
|
|
1346
|
+
deleteWordForward() {
|
|
1347
|
+
this.historyIndex = -1; // Exit history browsing mode
|
|
1348
|
+
const currentLine = this.state.lines[this.state.cursorLine] || "";
|
|
1349
|
+
// If at end of line, merge with next line (delete the newline)
|
|
1350
|
+
if (this.state.cursorCol >= currentLine.length) {
|
|
1351
|
+
if (this.state.cursorLine < this.state.lines.length - 1) {
|
|
1352
|
+
this.pushUndoSnapshot();
|
|
1353
|
+
// Treat newline as deleted text (forward deletion = append)
|
|
1354
|
+
this.killRing.push("\n", { prepend: false, accumulate: this.lastAction === "kill" });
|
|
1355
|
+
this.lastAction = "kill";
|
|
1356
|
+
const nextLine = this.state.lines[this.state.cursorLine + 1] || "";
|
|
1357
|
+
this.state.lines[this.state.cursorLine] = currentLine + nextLine;
|
|
1358
|
+
this.state.lines.splice(this.state.cursorLine + 1, 1);
|
|
1359
|
+
}
|
|
1360
|
+
}
|
|
1361
|
+
else {
|
|
1362
|
+
this.pushUndoSnapshot();
|
|
1363
|
+
// Save lastAction before cursor movement (moveWordForwards resets it)
|
|
1364
|
+
const wasKill = this.lastAction === "kill";
|
|
1365
|
+
const oldCursorCol = this.state.cursorCol;
|
|
1366
|
+
this.moveWordForwards();
|
|
1367
|
+
const deleteTo = this.state.cursorCol;
|
|
1368
|
+
this.setCursorCol(oldCursorCol);
|
|
1369
|
+
const deletedText = currentLine.slice(this.state.cursorCol, deleteTo);
|
|
1370
|
+
this.killRing.push(deletedText, { prepend: false, accumulate: wasKill });
|
|
1371
|
+
this.lastAction = "kill";
|
|
1372
|
+
this.state.lines[this.state.cursorLine] =
|
|
1373
|
+
currentLine.slice(0, this.state.cursorCol) + currentLine.slice(deleteTo);
|
|
1374
|
+
}
|
|
1375
|
+
if (this.onChange) {
|
|
1376
|
+
this.onChange(this.getText());
|
|
1377
|
+
}
|
|
1378
|
+
this.refreshAutocompleteAfterEdit();
|
|
1379
|
+
}
|
|
1380
|
+
handleForwardDelete() {
|
|
1381
|
+
this.historyIndex = -1; // Exit history browsing mode
|
|
1382
|
+
this.lastAction = null;
|
|
1383
|
+
const currentLine = this.state.lines[this.state.cursorLine] || "";
|
|
1384
|
+
if (this.state.cursorCol < currentLine.length) {
|
|
1385
|
+
this.pushUndoSnapshot();
|
|
1386
|
+
// Delete grapheme at cursor position (handles emojis, combining characters, etc.)
|
|
1387
|
+
const afterCursor = currentLine.slice(this.state.cursorCol);
|
|
1388
|
+
const graphemes = [...this.segment(afterCursor)];
|
|
1389
|
+
const firstGrapheme = graphemes[0];
|
|
1390
|
+
const graphemeLength = firstGrapheme ? firstGrapheme.segment.length : 1;
|
|
1391
|
+
const before = currentLine.slice(0, this.state.cursorCol);
|
|
1392
|
+
const after = currentLine.slice(this.state.cursorCol + graphemeLength);
|
|
1393
|
+
this.state.lines[this.state.cursorLine] = before + after;
|
|
1394
|
+
}
|
|
1395
|
+
else if (this.state.cursorLine < this.state.lines.length - 1) {
|
|
1396
|
+
this.pushUndoSnapshot();
|
|
1397
|
+
// At end of line - merge with next line
|
|
1398
|
+
const nextLine = this.state.lines[this.state.cursorLine + 1] || "";
|
|
1399
|
+
this.state.lines[this.state.cursorLine] = currentLine + nextLine;
|
|
1400
|
+
this.state.lines.splice(this.state.cursorLine + 1, 1);
|
|
1401
|
+
}
|
|
1402
|
+
if (this.onChange) {
|
|
1403
|
+
this.onChange(this.getText());
|
|
1404
|
+
}
|
|
1405
|
+
this.refreshAutocompleteAfterEdit(true);
|
|
1406
|
+
}
|
|
1407
|
+
/**
|
|
1408
|
+
* Build a mapping from visual lines to logical positions.
|
|
1409
|
+
* Returns an array where each element represents a visual line with:
|
|
1410
|
+
* - logicalLine: index into this.state.lines
|
|
1411
|
+
* - startCol: starting column in the logical line
|
|
1412
|
+
* - length: length of this visual line segment
|
|
1413
|
+
*/
|
|
1414
|
+
buildVisualLineMap(width) {
|
|
1415
|
+
const visualLines = [];
|
|
1416
|
+
for (let i = 0; i < this.state.lines.length; i++) {
|
|
1417
|
+
const line = this.state.lines[i] || "";
|
|
1418
|
+
const hiddenPrefixLength = this.getLineHiddenTextPrefixLength(i, line);
|
|
1419
|
+
const displayLine = line.slice(hiddenPrefixLength);
|
|
1420
|
+
const lineVisWidth = visibleWidth(displayLine);
|
|
1421
|
+
if (displayLine.length === 0) {
|
|
1422
|
+
visualLines.push({ logicalLine: i, startCol: hiddenPrefixLength, length: 0 });
|
|
1423
|
+
}
|
|
1424
|
+
else if (lineVisWidth <= width) {
|
|
1425
|
+
visualLines.push({ logicalLine: i, startCol: hiddenPrefixLength, length: displayLine.length });
|
|
1426
|
+
}
|
|
1427
|
+
else {
|
|
1428
|
+
const chunks = wordWrapLine(displayLine, width, [...this.segment(displayLine)]);
|
|
1429
|
+
for (const chunk of chunks) {
|
|
1430
|
+
visualLines.push({
|
|
1431
|
+
logicalLine: i,
|
|
1432
|
+
startCol: hiddenPrefixLength + chunk.startIndex,
|
|
1433
|
+
length: chunk.endIndex - chunk.startIndex,
|
|
1434
|
+
});
|
|
1435
|
+
}
|
|
1436
|
+
}
|
|
1437
|
+
}
|
|
1438
|
+
return visualLines;
|
|
1439
|
+
}
|
|
1440
|
+
/**
|
|
1441
|
+
* Find the visual line index that contains the given logical position.
|
|
1442
|
+
*/
|
|
1443
|
+
findVisualLineAt(visualLines, line, col) {
|
|
1444
|
+
const hiddenPrefixLength = this.getLineHiddenTextPrefixLength(line, this.state.lines[line] || "");
|
|
1445
|
+
for (let i = 0; i < visualLines.length; i++) {
|
|
1446
|
+
const vl = visualLines[i];
|
|
1447
|
+
if (!vl || vl.logicalLine !== line)
|
|
1448
|
+
continue;
|
|
1449
|
+
if (hiddenPrefixLength > 0 && col < hiddenPrefixLength && vl.startCol === hiddenPrefixLength) {
|
|
1450
|
+
return i;
|
|
1451
|
+
}
|
|
1452
|
+
const offset = col - vl.startCol;
|
|
1453
|
+
// Cursor is in this segment if it's within range. For the last
|
|
1454
|
+
// segment of a logical line, cursor can be at length (end position)
|
|
1455
|
+
const isLastSegmentOfLine = i === visualLines.length - 1 || visualLines[i + 1]?.logicalLine !== vl.logicalLine;
|
|
1456
|
+
if (offset >= 0 && (offset < vl.length || (isLastSegmentOfLine && offset === vl.length))) {
|
|
1457
|
+
return i;
|
|
1458
|
+
}
|
|
1459
|
+
}
|
|
1460
|
+
return visualLines.length - 1;
|
|
1461
|
+
}
|
|
1462
|
+
/**
|
|
1463
|
+
* Find the visual line index for the current cursor position.
|
|
1464
|
+
*/
|
|
1465
|
+
findCurrentVisualLine(visualLines) {
|
|
1466
|
+
return this.findVisualLineAt(visualLines, this.state.cursorLine, this.state.cursorCol);
|
|
1467
|
+
}
|
|
1468
|
+
moveCursor(deltaLine, deltaCol) {
|
|
1469
|
+
this.lastAction = null;
|
|
1470
|
+
const visualLines = this.buildVisualLineMap(this.lastWidth);
|
|
1471
|
+
const currentVisualLine = this.findCurrentVisualLine(visualLines);
|
|
1472
|
+
if (deltaLine !== 0) {
|
|
1473
|
+
const targetVisualLine = currentVisualLine + deltaLine;
|
|
1474
|
+
if (targetVisualLine >= 0 && targetVisualLine < visualLines.length) {
|
|
1475
|
+
this.moveToVisualLine(visualLines, currentVisualLine, targetVisualLine);
|
|
1476
|
+
}
|
|
1477
|
+
}
|
|
1478
|
+
if (deltaCol !== 0) {
|
|
1479
|
+
const currentLine = this.state.lines[this.state.cursorLine] || "";
|
|
1480
|
+
const lineStartCol = this.getLineHiddenTextPrefixLength(this.state.cursorLine, currentLine);
|
|
1481
|
+
if (deltaCol > 0) {
|
|
1482
|
+
// Moving right - move by one grapheme (handles emojis, combining characters, etc.)
|
|
1483
|
+
if (this.state.cursorCol < currentLine.length) {
|
|
1484
|
+
const afterCursor = currentLine.slice(this.state.cursorCol);
|
|
1485
|
+
const graphemes = [...this.segment(afterCursor)];
|
|
1486
|
+
const firstGrapheme = graphemes[0];
|
|
1487
|
+
this.setCursorCol(this.state.cursorCol + (firstGrapheme ? firstGrapheme.segment.length : 1));
|
|
1488
|
+
}
|
|
1489
|
+
else if (this.state.cursorLine < this.state.lines.length - 1) {
|
|
1490
|
+
this.state.cursorLine++;
|
|
1491
|
+
this.setCursorCol(0);
|
|
1492
|
+
}
|
|
1493
|
+
else {
|
|
1494
|
+
const currentVL = visualLines[currentVisualLine];
|
|
1495
|
+
if (currentVL) {
|
|
1496
|
+
this.preferredVisualCol = this.state.cursorCol - currentVL.startCol;
|
|
1497
|
+
}
|
|
1498
|
+
}
|
|
1499
|
+
}
|
|
1500
|
+
else {
|
|
1501
|
+
// Moving left - move by one grapheme (handles emojis, combining characters, etc.)
|
|
1502
|
+
if (this.state.cursorCol > lineStartCol) {
|
|
1503
|
+
const beforeCursor = currentLine.slice(lineStartCol, this.state.cursorCol);
|
|
1504
|
+
const graphemes = [...this.segment(beforeCursor)];
|
|
1505
|
+
const lastGrapheme = graphemes[graphemes.length - 1];
|
|
1506
|
+
const previousCol = this.state.cursorCol - (lastGrapheme ? lastGrapheme.segment.length : 1);
|
|
1507
|
+
this.setCursorCol(Math.max(lineStartCol, previousCol));
|
|
1508
|
+
}
|
|
1509
|
+
else if (this.state.cursorLine > 0) {
|
|
1510
|
+
this.state.cursorLine--;
|
|
1511
|
+
const prevLine = this.state.lines[this.state.cursorLine] || "";
|
|
1512
|
+
this.setCursorCol(prevLine.length);
|
|
1513
|
+
}
|
|
1514
|
+
}
|
|
1515
|
+
}
|
|
1516
|
+
}
|
|
1517
|
+
/**
|
|
1518
|
+
* Scroll by a page (direction: -1 for up, 1 for down).
|
|
1519
|
+
* Moves cursor by the page size while keeping it in bounds.
|
|
1520
|
+
*/
|
|
1521
|
+
pageScroll(direction) {
|
|
1522
|
+
this.lastAction = null;
|
|
1523
|
+
const terminalRows = this.tui.terminal.rows;
|
|
1524
|
+
const pageSize = Math.max(5, Math.floor(terminalRows * 0.3));
|
|
1525
|
+
const visualLines = this.buildVisualLineMap(this.lastWidth);
|
|
1526
|
+
const currentVisualLine = this.findCurrentVisualLine(visualLines);
|
|
1527
|
+
const targetVisualLine = Math.max(0, Math.min(visualLines.length - 1, currentVisualLine + direction * pageSize));
|
|
1528
|
+
this.moveToVisualLine(visualLines, currentVisualLine, targetVisualLine);
|
|
1529
|
+
}
|
|
1530
|
+
moveWordBackwards() {
|
|
1531
|
+
this.lastAction = null;
|
|
1532
|
+
const currentLine = this.state.lines[this.state.cursorLine] || "";
|
|
1533
|
+
const lineStartCol = this.getLineHiddenTextPrefixLength(this.state.cursorLine, currentLine);
|
|
1534
|
+
if (this.state.cursorCol <= lineStartCol) {
|
|
1535
|
+
if (this.state.cursorLine > 0) {
|
|
1536
|
+
this.state.cursorLine--;
|
|
1537
|
+
const prevLine = this.state.lines[this.state.cursorLine] || "";
|
|
1538
|
+
this.setCursorCol(prevLine.length);
|
|
1539
|
+
}
|
|
1540
|
+
return;
|
|
1541
|
+
}
|
|
1542
|
+
const textBeforeCursor = currentLine.slice(lineStartCol, this.state.cursorCol);
|
|
1543
|
+
const graphemes = [...this.segment(textBeforeCursor)];
|
|
1544
|
+
let newCol = this.state.cursorCol;
|
|
1545
|
+
while (graphemes.length > 0 &&
|
|
1546
|
+
!isAtomicMarker(graphemes[graphemes.length - 1]?.segment || "") &&
|
|
1547
|
+
isWhitespaceChar(graphemes[graphemes.length - 1]?.segment || "")) {
|
|
1548
|
+
newCol -= graphemes.pop()?.segment.length || 0;
|
|
1549
|
+
}
|
|
1550
|
+
if (graphemes.length > 0) {
|
|
1551
|
+
const lastGrapheme = graphemes[graphemes.length - 1]?.segment || "";
|
|
1552
|
+
if (isAtomicMarker(lastGrapheme)) {
|
|
1553
|
+
newCol -= graphemes.pop()?.segment.length || 0;
|
|
1554
|
+
}
|
|
1555
|
+
else if (isPunctuationChar(lastGrapheme)) {
|
|
1556
|
+
while (graphemes.length > 0 &&
|
|
1557
|
+
isPunctuationChar(graphemes[graphemes.length - 1]?.segment || "") &&
|
|
1558
|
+
!isAtomicMarker(graphemes[graphemes.length - 1]?.segment || "")) {
|
|
1559
|
+
newCol -= graphemes.pop()?.segment.length || 0;
|
|
1560
|
+
}
|
|
1561
|
+
}
|
|
1562
|
+
else {
|
|
1563
|
+
while (graphemes.length > 0 &&
|
|
1564
|
+
!isWhitespaceChar(graphemes[graphemes.length - 1]?.segment || "") &&
|
|
1565
|
+
!isPunctuationChar(graphemes[graphemes.length - 1]?.segment || "") &&
|
|
1566
|
+
!isAtomicMarker(graphemes[graphemes.length - 1]?.segment || "")) {
|
|
1567
|
+
newCol -= graphemes.pop()?.segment.length || 0;
|
|
1568
|
+
}
|
|
1569
|
+
}
|
|
1570
|
+
}
|
|
1571
|
+
this.setCursorCol(Math.max(lineStartCol, newCol));
|
|
1572
|
+
}
|
|
1573
|
+
/**
|
|
1574
|
+
* Yank (paste) the most recent kill ring entry at cursor position.
|
|
1575
|
+
*/
|
|
1576
|
+
yank() {
|
|
1577
|
+
if (this.killRing.length === 0)
|
|
1578
|
+
return;
|
|
1579
|
+
this.pushUndoSnapshot();
|
|
1580
|
+
const text = this.killRing.peek();
|
|
1581
|
+
this.insertYankedText(text);
|
|
1582
|
+
this.lastAction = "yank";
|
|
1583
|
+
this.refreshAutocompleteAfterEdit();
|
|
1584
|
+
}
|
|
1585
|
+
/**
|
|
1586
|
+
* Cycle through kill ring (only works immediately after yank or yank-pop).
|
|
1587
|
+
* Replaces the last yanked text with the previous entry in the ring.
|
|
1588
|
+
*/
|
|
1589
|
+
yankPop() {
|
|
1590
|
+
if (this.lastAction !== "yank" || this.killRing.length <= 1)
|
|
1591
|
+
return;
|
|
1592
|
+
this.pushUndoSnapshot();
|
|
1593
|
+
this.deleteYankedText();
|
|
1594
|
+
this.killRing.rotate();
|
|
1595
|
+
const text = this.killRing.peek();
|
|
1596
|
+
this.insertYankedText(text);
|
|
1597
|
+
this.lastAction = "yank";
|
|
1598
|
+
this.refreshAutocompleteAfterEdit();
|
|
1599
|
+
}
|
|
1600
|
+
/**
|
|
1601
|
+
* Insert text at cursor position (used by yank operations).
|
|
1602
|
+
*/
|
|
1603
|
+
insertYankedText(text) {
|
|
1604
|
+
this.historyIndex = -1; // Exit history browsing mode
|
|
1605
|
+
const lines = text.split("\n");
|
|
1606
|
+
if (lines.length === 1) {
|
|
1607
|
+
const currentLine = this.state.lines[this.state.cursorLine] || "";
|
|
1608
|
+
const before = currentLine.slice(0, this.state.cursorCol);
|
|
1609
|
+
const after = currentLine.slice(this.state.cursorCol);
|
|
1610
|
+
this.state.lines[this.state.cursorLine] = before + text + after;
|
|
1611
|
+
this.setCursorCol(this.state.cursorCol + text.length);
|
|
1612
|
+
}
|
|
1613
|
+
else {
|
|
1614
|
+
const currentLine = this.state.lines[this.state.cursorLine] || "";
|
|
1615
|
+
const before = currentLine.slice(0, this.state.cursorCol);
|
|
1616
|
+
const after = currentLine.slice(this.state.cursorCol);
|
|
1617
|
+
// First line merges with text before cursor
|
|
1618
|
+
this.state.lines[this.state.cursorLine] = before + (lines[0] || "");
|
|
1619
|
+
// Insert middle lines
|
|
1620
|
+
for (let i = 1; i < lines.length - 1; i++) {
|
|
1621
|
+
this.state.lines.splice(this.state.cursorLine + i, 0, lines[i] || "");
|
|
1622
|
+
}
|
|
1623
|
+
// Last line merges with text after cursor
|
|
1624
|
+
const lastLineIndex = this.state.cursorLine + lines.length - 1;
|
|
1625
|
+
this.state.lines.splice(lastLineIndex, 0, (lines[lines.length - 1] || "") + after);
|
|
1626
|
+
// Update cursor position
|
|
1627
|
+
this.state.cursorLine = lastLineIndex;
|
|
1628
|
+
this.setCursorCol((lines[lines.length - 1] || "").length);
|
|
1629
|
+
}
|
|
1630
|
+
if (this.onChange) {
|
|
1631
|
+
this.onChange(this.getText());
|
|
1632
|
+
}
|
|
1633
|
+
}
|
|
1634
|
+
/**
|
|
1635
|
+
* Delete the previously yanked text (used by yank-pop).
|
|
1636
|
+
* The yanked text is derived from killRing[end] since it hasn't been rotated yet.
|
|
1637
|
+
*/
|
|
1638
|
+
deleteYankedText() {
|
|
1639
|
+
const yankedText = this.killRing.peek();
|
|
1640
|
+
if (!yankedText)
|
|
1641
|
+
return;
|
|
1642
|
+
const yankLines = yankedText.split("\n");
|
|
1643
|
+
if (yankLines.length === 1) {
|
|
1644
|
+
const currentLine = this.state.lines[this.state.cursorLine] || "";
|
|
1645
|
+
const deleteLen = yankedText.length;
|
|
1646
|
+
const before = currentLine.slice(0, this.state.cursorCol - deleteLen);
|
|
1647
|
+
const after = currentLine.slice(this.state.cursorCol);
|
|
1648
|
+
this.state.lines[this.state.cursorLine] = before + after;
|
|
1649
|
+
this.setCursorCol(this.state.cursorCol - deleteLen);
|
|
1650
|
+
}
|
|
1651
|
+
else {
|
|
1652
|
+
const startLine = this.state.cursorLine - (yankLines.length - 1);
|
|
1653
|
+
const startCol = (this.state.lines[startLine] || "").length - (yankLines[0] || "").length;
|
|
1654
|
+
// Get text after cursor on current line
|
|
1655
|
+
const afterCursor = (this.state.lines[this.state.cursorLine] || "").slice(this.state.cursorCol);
|
|
1656
|
+
// Get text before yank start position
|
|
1657
|
+
const beforeYank = (this.state.lines[startLine] || "").slice(0, startCol);
|
|
1658
|
+
// Remove all lines from startLine to cursorLine and replace with merged line
|
|
1659
|
+
this.state.lines.splice(startLine, yankLines.length, beforeYank + afterCursor);
|
|
1660
|
+
// Update cursor
|
|
1661
|
+
this.state.cursorLine = startLine;
|
|
1662
|
+
this.setCursorCol(startCol);
|
|
1663
|
+
}
|
|
1664
|
+
if (this.onChange) {
|
|
1665
|
+
this.onChange(this.getText());
|
|
1666
|
+
}
|
|
1667
|
+
}
|
|
1668
|
+
pushUndoSnapshot() {
|
|
1669
|
+
this.undoStack.push({
|
|
1670
|
+
lines: this.state.lines,
|
|
1671
|
+
cursorLine: this.state.cursorLine,
|
|
1672
|
+
cursorCol: this.state.cursorCol,
|
|
1673
|
+
pastes: this.pastes,
|
|
1674
|
+
pasteCounter: this.pasteCounter,
|
|
1675
|
+
});
|
|
1676
|
+
}
|
|
1677
|
+
undo() {
|
|
1678
|
+
this.historyIndex = -1; // Exit history browsing mode
|
|
1679
|
+
const snapshot = this.undoStack.pop();
|
|
1680
|
+
if (!snapshot)
|
|
1681
|
+
return;
|
|
1682
|
+
this.state = {
|
|
1683
|
+
lines: snapshot.lines,
|
|
1684
|
+
cursorLine: snapshot.cursorLine,
|
|
1685
|
+
cursorCol: snapshot.cursorCol,
|
|
1686
|
+
};
|
|
1687
|
+
this.pastes = new Map(snapshot.pastes);
|
|
1688
|
+
this.pasteCounter = snapshot.pasteCounter;
|
|
1689
|
+
this.lastAction = null;
|
|
1690
|
+
this.preferredVisualCol = null;
|
|
1691
|
+
if (this.onChange) {
|
|
1692
|
+
this.onChange(this.getText());
|
|
1693
|
+
}
|
|
1694
|
+
this.refreshAutocompleteAfterEdit();
|
|
1695
|
+
}
|
|
1696
|
+
/**
|
|
1697
|
+
* Jump to the first occurrence of a character in the specified direction.
|
|
1698
|
+
* Multi-line search. Case-sensitive. Skips the current cursor position.
|
|
1699
|
+
*/
|
|
1700
|
+
jumpToChar(char, direction) {
|
|
1701
|
+
this.lastAction = null;
|
|
1702
|
+
const isForward = direction === "forward";
|
|
1703
|
+
const lines = this.state.lines;
|
|
1704
|
+
const end = isForward ? lines.length : -1;
|
|
1705
|
+
const step = isForward ? 1 : -1;
|
|
1706
|
+
for (let lineIdx = this.state.cursorLine; lineIdx !== end; lineIdx += step) {
|
|
1707
|
+
const line = lines[lineIdx] || "";
|
|
1708
|
+
const isCurrentLine = lineIdx === this.state.cursorLine;
|
|
1709
|
+
const searchFrom = isCurrentLine
|
|
1710
|
+
? isForward
|
|
1711
|
+
? this.state.cursorCol + 1
|
|
1712
|
+
: this.state.cursorCol - 1
|
|
1713
|
+
: undefined;
|
|
1714
|
+
const idx = isForward ? line.indexOf(char, searchFrom) : line.lastIndexOf(char, searchFrom);
|
|
1715
|
+
if (idx !== -1) {
|
|
1716
|
+
this.state.cursorLine = lineIdx;
|
|
1717
|
+
this.setCursorCol(idx);
|
|
1718
|
+
return;
|
|
1719
|
+
}
|
|
1720
|
+
}
|
|
1721
|
+
}
|
|
1722
|
+
moveWordForwards() {
|
|
1723
|
+
this.lastAction = null;
|
|
1724
|
+
const currentLine = this.state.lines[this.state.cursorLine] || "";
|
|
1725
|
+
if (this.state.cursorCol >= currentLine.length) {
|
|
1726
|
+
if (this.state.cursorLine < this.state.lines.length - 1) {
|
|
1727
|
+
this.state.cursorLine++;
|
|
1728
|
+
this.setCursorCol(0);
|
|
1729
|
+
}
|
|
1730
|
+
return;
|
|
1731
|
+
}
|
|
1732
|
+
const textAfterCursor = currentLine.slice(this.state.cursorCol);
|
|
1733
|
+
const segments = this.segment(textAfterCursor);
|
|
1734
|
+
const iterator = segments[Symbol.iterator]();
|
|
1735
|
+
let next = iterator.next();
|
|
1736
|
+
let newCol = this.state.cursorCol;
|
|
1737
|
+
while (!next.done && !isAtomicMarker(next.value.segment) && isWhitespaceChar(next.value.segment)) {
|
|
1738
|
+
newCol += next.value.segment.length;
|
|
1739
|
+
next = iterator.next();
|
|
1740
|
+
}
|
|
1741
|
+
if (!next.done) {
|
|
1742
|
+
const firstGrapheme = next.value.segment;
|
|
1743
|
+
if (isAtomicMarker(firstGrapheme)) {
|
|
1744
|
+
newCol += firstGrapheme.length;
|
|
1745
|
+
}
|
|
1746
|
+
else if (isPunctuationChar(firstGrapheme)) {
|
|
1747
|
+
while (!next.done && isPunctuationChar(next.value.segment) && !isAtomicMarker(next.value.segment)) {
|
|
1748
|
+
newCol += next.value.segment.length;
|
|
1749
|
+
next = iterator.next();
|
|
1750
|
+
}
|
|
1751
|
+
}
|
|
1752
|
+
else {
|
|
1753
|
+
while (!next.done &&
|
|
1754
|
+
!isWhitespaceChar(next.value.segment) &&
|
|
1755
|
+
!isPunctuationChar(next.value.segment) &&
|
|
1756
|
+
!isAtomicMarker(next.value.segment)) {
|
|
1757
|
+
newCol += next.value.segment.length;
|
|
1758
|
+
next = iterator.next();
|
|
1759
|
+
}
|
|
1760
|
+
}
|
|
1761
|
+
}
|
|
1762
|
+
this.setCursorCol(newCol);
|
|
1763
|
+
}
|
|
1764
|
+
getCurrentSlashCommandContext() {
|
|
1765
|
+
return getSlashCommandContext(this.state.lines, this.state.cursorLine, this.state.cursorCol);
|
|
1766
|
+
}
|
|
1767
|
+
/**
|
|
1768
|
+
* Find the best autocomplete item index for the given prefix.
|
|
1769
|
+
* Returns -1 if no match is found.
|
|
1770
|
+
*
|
|
1771
|
+
* Match priority:
|
|
1772
|
+
* 1. Exact match (prefix === item.value) -> always selected
|
|
1773
|
+
* 2. Prefix match -> first item whose value starts with prefix
|
|
1774
|
+
* 3. No match -> -1 (keep default highlight)
|
|
1775
|
+
*
|
|
1776
|
+
* Matching is case-sensitive and checks item.value only.
|
|
1777
|
+
*/
|
|
1778
|
+
getBestAutocompleteMatchIndex(items, prefix) {
|
|
1779
|
+
if (!prefix)
|
|
1780
|
+
return -1;
|
|
1781
|
+
let firstPrefixIndex = -1;
|
|
1782
|
+
for (let i = 0; i < items.length; i++) {
|
|
1783
|
+
const value = items[i].value;
|
|
1784
|
+
if (value === prefix) {
|
|
1785
|
+
return i; // Exact match always wins
|
|
1786
|
+
}
|
|
1787
|
+
if (firstPrefixIndex === -1 && value.startsWith(prefix)) {
|
|
1788
|
+
firstPrefixIndex = i;
|
|
1789
|
+
}
|
|
1790
|
+
}
|
|
1791
|
+
return firstPrefixIndex;
|
|
1792
|
+
}
|
|
1793
|
+
createAutocompleteList(suggestions) {
|
|
1794
|
+
const layout = suggestions.kind === "slash-command" || (suggestions.kind === undefined && suggestions.prefix.startsWith("/"))
|
|
1795
|
+
? SLASH_COMMAND_SELECT_LIST_LAYOUT
|
|
1796
|
+
: undefined;
|
|
1797
|
+
return new SelectList(suggestions.items, this.autocompleteMaxVisible, this.theme.selectList, layout);
|
|
1798
|
+
}
|
|
1799
|
+
tryTriggerAutocomplete(explicitTab = false) {
|
|
1800
|
+
this.requestAutocomplete({ force: false, explicitTab });
|
|
1801
|
+
}
|
|
1802
|
+
handleTabCompletion() {
|
|
1803
|
+
if (!this.autocompleteProvider)
|
|
1804
|
+
return;
|
|
1805
|
+
if (this.getCurrentSlashCommandContext()?.kind === "name") {
|
|
1806
|
+
this.handleSlashCommandCompletion();
|
|
1807
|
+
}
|
|
1808
|
+
else {
|
|
1809
|
+
this.forceFileAutocomplete(true);
|
|
1810
|
+
}
|
|
1811
|
+
}
|
|
1812
|
+
handleSlashCommandCompletion() {
|
|
1813
|
+
this.requestAutocomplete({ force: false, explicitTab: true });
|
|
1814
|
+
}
|
|
1815
|
+
forceFileAutocomplete(explicitTab = false) {
|
|
1816
|
+
this.requestAutocomplete({ force: true, explicitTab });
|
|
1817
|
+
}
|
|
1818
|
+
requestAutocomplete(options) {
|
|
1819
|
+
if (!this.autocompleteProvider)
|
|
1820
|
+
return;
|
|
1821
|
+
if (options.force) {
|
|
1822
|
+
const shouldTrigger = !this.autocompleteProvider.shouldTriggerFileCompletion ||
|
|
1823
|
+
this.autocompleteProvider.shouldTriggerFileCompletion(this.state.lines, this.state.cursorLine, this.state.cursorCol);
|
|
1824
|
+
if (!shouldTrigger) {
|
|
1825
|
+
return;
|
|
1826
|
+
}
|
|
1827
|
+
}
|
|
1828
|
+
this.cancelAutocompleteRequest();
|
|
1829
|
+
const startToken = ++this.autocompleteStartToken;
|
|
1830
|
+
const debounceMs = this.getAutocompleteDebounceMs(options);
|
|
1831
|
+
if (debounceMs > 0) {
|
|
1832
|
+
this.autocompleteDebounceTimer = setTimeout(() => {
|
|
1833
|
+
this.autocompleteDebounceTimer = undefined;
|
|
1834
|
+
void this.startAutocompleteRequest(startToken, options);
|
|
1835
|
+
}, debounceMs);
|
|
1836
|
+
return;
|
|
1837
|
+
}
|
|
1838
|
+
void this.startAutocompleteRequest(startToken, options);
|
|
1839
|
+
}
|
|
1840
|
+
async startAutocompleteRequest(startToken, options) {
|
|
1841
|
+
const previousTask = this.autocompleteRequestTask;
|
|
1842
|
+
this.autocompleteRequestTask = (async () => {
|
|
1843
|
+
await previousTask;
|
|
1844
|
+
if (startToken !== this.autocompleteStartToken || !this.autocompleteProvider) {
|
|
1845
|
+
return;
|
|
1846
|
+
}
|
|
1847
|
+
const controller = new AbortController();
|
|
1848
|
+
this.autocompleteAbort = controller;
|
|
1849
|
+
const requestId = ++this.autocompleteRequestId;
|
|
1850
|
+
const snapshotText = this.getText();
|
|
1851
|
+
const snapshotLine = this.state.cursorLine;
|
|
1852
|
+
const snapshotCol = this.state.cursorCol;
|
|
1853
|
+
await this.runAutocompleteRequest(requestId, controller, snapshotText, snapshotLine, snapshotCol, options);
|
|
1854
|
+
})();
|
|
1855
|
+
await this.autocompleteRequestTask;
|
|
1856
|
+
}
|
|
1857
|
+
getAutocompleteDebounceMs(options) {
|
|
1858
|
+
if (options.explicitTab || options.force) {
|
|
1859
|
+
return 0;
|
|
1860
|
+
}
|
|
1861
|
+
const currentLine = this.state.lines[this.state.cursorLine] || "";
|
|
1862
|
+
const textBeforeCursor = currentLine.slice(0, this.state.cursorCol);
|
|
1863
|
+
const isSymbolAutocompleteContext = /(?:^|[ \t])(?:@(?:"[^"]*|[^\s]*)|#[^\s]*)$/.test(textBeforeCursor);
|
|
1864
|
+
return isSymbolAutocompleteContext ? ATTACHMENT_AUTOCOMPLETE_DEBOUNCE_MS : 0;
|
|
1865
|
+
}
|
|
1866
|
+
async runAutocompleteRequest(requestId, controller, snapshotText, snapshotLine, snapshotCol, options) {
|
|
1867
|
+
if (!this.autocompleteProvider)
|
|
1868
|
+
return;
|
|
1869
|
+
const suggestions = await this.autocompleteProvider.getSuggestions(this.state.lines, this.state.cursorLine, this.state.cursorCol, { signal: controller.signal, force: options.force });
|
|
1870
|
+
if (!this.isAutocompleteRequestCurrent(requestId, controller, snapshotText, snapshotLine, snapshotCol)) {
|
|
1871
|
+
return;
|
|
1872
|
+
}
|
|
1873
|
+
this.autocompleteAbort = undefined;
|
|
1874
|
+
if (!suggestions || !Array.isArray(suggestions.items) || suggestions.items.length === 0) {
|
|
1875
|
+
this.cancelAutocomplete();
|
|
1876
|
+
this.tui.requestRender();
|
|
1877
|
+
return;
|
|
1878
|
+
}
|
|
1879
|
+
if (options.force && options.explicitTab && suggestions.items.length === 1) {
|
|
1880
|
+
const item = suggestions.items[0];
|
|
1881
|
+
this.pushUndoSnapshot();
|
|
1882
|
+
this.lastAction = null;
|
|
1883
|
+
const result = this.autocompleteProvider.applyCompletion(this.state.lines, this.state.cursorLine, this.state.cursorCol, item, suggestions.prefix);
|
|
1884
|
+
this.state.lines = result.lines;
|
|
1885
|
+
this.state.cursorLine = result.cursorLine;
|
|
1886
|
+
this.setCursorCol(result.cursorCol);
|
|
1887
|
+
if (this.onChange)
|
|
1888
|
+
this.onChange(this.getText());
|
|
1889
|
+
this.tui.requestRender();
|
|
1890
|
+
return;
|
|
1891
|
+
}
|
|
1892
|
+
this.applyAutocompleteSuggestions(suggestions, options.force ? "force" : "regular");
|
|
1893
|
+
this.tui.requestRender();
|
|
1894
|
+
}
|
|
1895
|
+
isAutocompleteRequestCurrent(requestId, controller, snapshotText, snapshotLine, snapshotCol) {
|
|
1896
|
+
return (!controller.signal.aborted &&
|
|
1897
|
+
requestId === this.autocompleteRequestId &&
|
|
1898
|
+
this.getText() === snapshotText &&
|
|
1899
|
+
this.state.cursorLine === snapshotLine &&
|
|
1900
|
+
this.state.cursorCol === snapshotCol);
|
|
1901
|
+
}
|
|
1902
|
+
applyAutocompleteSuggestions(suggestions, state) {
|
|
1903
|
+
this.autocompletePrefix = suggestions.prefix;
|
|
1904
|
+
this.autocompleteKind = suggestions.kind;
|
|
1905
|
+
this.autocompleteList = this.createAutocompleteList(suggestions);
|
|
1906
|
+
const matchingPrefix = suggestions.kind === "slash-command" ? suggestions.prefix.slice(1) : suggestions.prefix;
|
|
1907
|
+
const bestMatchIndex = this.getBestAutocompleteMatchIndex(suggestions.items, matchingPrefix);
|
|
1908
|
+
if (bestMatchIndex >= 0) {
|
|
1909
|
+
this.autocompleteList.setSelectedIndex(bestMatchIndex);
|
|
1910
|
+
}
|
|
1911
|
+
this.autocompleteState = state;
|
|
1912
|
+
if (!this.autocompleteOverlay) {
|
|
1913
|
+
this.autocompleteOverlay = this.tui.showOverlay(this.autocompleteOverlayComponent, {
|
|
1914
|
+
width: "100%",
|
|
1915
|
+
aboveMarker: this.autocompleteAnchorMarker,
|
|
1916
|
+
offsetY: -1,
|
|
1917
|
+
nonCapturing: true,
|
|
1918
|
+
visible: () => this.focused && this.autocompleteState !== null,
|
|
1919
|
+
});
|
|
1920
|
+
}
|
|
1921
|
+
}
|
|
1922
|
+
cancelAutocompleteRequest() {
|
|
1923
|
+
this.autocompleteStartToken += 1;
|
|
1924
|
+
if (this.autocompleteDebounceTimer) {
|
|
1925
|
+
clearTimeout(this.autocompleteDebounceTimer);
|
|
1926
|
+
this.autocompleteDebounceTimer = undefined;
|
|
1927
|
+
}
|
|
1928
|
+
this.autocompleteAbort?.abort();
|
|
1929
|
+
this.autocompleteAbort = undefined;
|
|
1930
|
+
}
|
|
1931
|
+
clearAutocompleteUi() {
|
|
1932
|
+
this.autocompleteOverlay?.hide();
|
|
1933
|
+
this.autocompleteOverlay = undefined;
|
|
1934
|
+
this.autocompleteState = null;
|
|
1935
|
+
this.autocompleteList = undefined;
|
|
1936
|
+
this.autocompletePrefix = "";
|
|
1937
|
+
this.autocompleteKind = undefined;
|
|
1938
|
+
}
|
|
1939
|
+
cancelAutocomplete() {
|
|
1940
|
+
this.cancelAutocompleteRequest();
|
|
1941
|
+
this.clearAutocompleteUi();
|
|
1942
|
+
}
|
|
1943
|
+
isShowingAutocomplete() {
|
|
1944
|
+
return this.autocompleteState !== null;
|
|
1945
|
+
}
|
|
1946
|
+
refreshAutocompleteAfterEdit(retrigger = false) {
|
|
1947
|
+
const currentLine = this.state.lines[this.state.cursorLine] || "";
|
|
1948
|
+
const textBeforeCursor = currentLine.slice(0, this.state.cursorCol);
|
|
1949
|
+
const hasCompletionContext = this.getCurrentSlashCommandContext() !== null || /(?:^|[\s])[@#][^\s]*$/.test(textBeforeCursor);
|
|
1950
|
+
if (this.autocompleteState) {
|
|
1951
|
+
if (this.getText().trim().length === 0 || (this.autocompleteState === "regular" && !hasCompletionContext)) {
|
|
1952
|
+
this.cancelAutocomplete();
|
|
1953
|
+
return;
|
|
1954
|
+
}
|
|
1955
|
+
this.updateAutocomplete();
|
|
1956
|
+
return;
|
|
1957
|
+
}
|
|
1958
|
+
if (retrigger && hasCompletionContext) {
|
|
1959
|
+
this.tryTriggerAutocomplete();
|
|
1960
|
+
}
|
|
1961
|
+
}
|
|
1962
|
+
updateAutocomplete() {
|
|
1963
|
+
if (!this.autocompleteState || !this.autocompleteProvider)
|
|
1964
|
+
return;
|
|
1965
|
+
this.requestAutocomplete({ force: this.autocompleteState === "force", explicitTab: false });
|
|
1966
|
+
}
|
|
1967
|
+
}
|
|
1968
|
+
//# sourceMappingURL=editor.js.map
|