@nghyane/arcane-tui 0.1.0
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/CHANGELOG.md +3 -0
- package/README.md +704 -0
- package/package.json +72 -0
- package/src/autocomplete.ts +772 -0
- package/src/buffer/ansi-parser.ts +349 -0
- package/src/buffer/buffer.ts +120 -0
- package/src/buffer/cell.ts +103 -0
- package/src/buffer/index.ts +16 -0
- package/src/buffer/render.ts +149 -0
- package/src/components/box.ts +144 -0
- package/src/components/cancellable-loader.ts +39 -0
- package/src/components/editor.ts +2289 -0
- package/src/components/image.ts +86 -0
- package/src/components/input.ts +531 -0
- package/src/components/loader.ts +59 -0
- package/src/components/markdown.ts +858 -0
- package/src/components/select-list.ts +198 -0
- package/src/components/settings-list.ts +194 -0
- package/src/components/spacer.ts +28 -0
- package/src/components/tab-bar.ts +142 -0
- package/src/components/text.ts +110 -0
- package/src/components/truncated-text.ts +61 -0
- package/src/editor-component.ts +71 -0
- package/src/fuzzy.ts +143 -0
- package/src/index.ts +69 -0
- package/src/keybindings.ts +197 -0
- package/src/keys.ts +270 -0
- package/src/kill-ring.ts +46 -0
- package/src/mermaid.ts +140 -0
- package/src/stdin-buffer.ts +385 -0
- package/src/symbols.ts +24 -0
- package/src/terminal-capabilities.ts +393 -0
- package/src/terminal.ts +467 -0
- package/src/ttyid.ts +66 -0
- package/src/tui.ts +1134 -0
- package/src/utils.ts +149 -0
|
@@ -0,0 +1,2289 @@
|
|
|
1
|
+
import { getProjectDir } from "@nghyane/arcane-utils/dirs";
|
|
2
|
+
import type { AutocompleteProvider, CombinedAutocompleteProvider } from "../autocomplete";
|
|
3
|
+
import { type EditorKeybindingsManager, getEditorKeybindings } from "../keybindings";
|
|
4
|
+
import { matchesKey } from "../keys";
|
|
5
|
+
import { KillRing } from "../kill-ring";
|
|
6
|
+
import type { SymbolTheme } from "../symbols";
|
|
7
|
+
import { type Component, CURSOR_MARKER, type Focusable } from "../tui";
|
|
8
|
+
import { getSegmenter, isPunctuationChar, isWhitespaceChar, padding, truncateToWidth, visibleWidth } from "../utils";
|
|
9
|
+
import { SelectList, type SelectListTheme } from "./select-list";
|
|
10
|
+
|
|
11
|
+
const segmenter = getSegmenter();
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Represents a chunk of text for word-wrap layout.
|
|
15
|
+
* Tracks both the text content and its position in the original line.
|
|
16
|
+
*/
|
|
17
|
+
interface TextChunk {
|
|
18
|
+
text: string;
|
|
19
|
+
startIndex: number;
|
|
20
|
+
endIndex: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Split a line into word-wrapped chunks.
|
|
25
|
+
* Wraps at word boundaries when possible, falling back to character-level
|
|
26
|
+
* wrapping for words longer than the available width.
|
|
27
|
+
*
|
|
28
|
+
* @param line - The text line to wrap
|
|
29
|
+
* @param maxWidth - Maximum visible width per chunk
|
|
30
|
+
* @returns Array of chunks with text and position information
|
|
31
|
+
*/
|
|
32
|
+
function wordWrapLine(line: string, maxWidth: number): TextChunk[] {
|
|
33
|
+
if (!line || maxWidth <= 0) {
|
|
34
|
+
return [{ text: "", startIndex: 0, endIndex: 0 }];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const lineWidth = visibleWidth(line);
|
|
38
|
+
if (lineWidth <= maxWidth) {
|
|
39
|
+
return [{ text: line, startIndex: 0, endIndex: line.length }];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const chunks: TextChunk[] = [];
|
|
43
|
+
|
|
44
|
+
// Split into tokens (words and whitespace runs)
|
|
45
|
+
const tokens: { text: string; startIndex: number; endIndex: number; isWhitespace: boolean }[] = [];
|
|
46
|
+
let currentToken = "";
|
|
47
|
+
let tokenStart = 0;
|
|
48
|
+
let inWhitespace = false;
|
|
49
|
+
let charIndex = 0;
|
|
50
|
+
|
|
51
|
+
for (const seg of segmenter.segment(line)) {
|
|
52
|
+
const grapheme = seg.segment;
|
|
53
|
+
const graphemeIsWhitespace = isWhitespaceChar(grapheme);
|
|
54
|
+
|
|
55
|
+
if (currentToken === "") {
|
|
56
|
+
inWhitespace = graphemeIsWhitespace;
|
|
57
|
+
tokenStart = charIndex;
|
|
58
|
+
} else if (graphemeIsWhitespace !== inWhitespace) {
|
|
59
|
+
// Token type changed - save current token
|
|
60
|
+
tokens.push({
|
|
61
|
+
text: currentToken,
|
|
62
|
+
startIndex: tokenStart,
|
|
63
|
+
endIndex: charIndex,
|
|
64
|
+
isWhitespace: inWhitespace,
|
|
65
|
+
});
|
|
66
|
+
currentToken = "";
|
|
67
|
+
tokenStart = charIndex;
|
|
68
|
+
inWhitespace = graphemeIsWhitespace;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
currentToken += grapheme;
|
|
72
|
+
charIndex += grapheme.length;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Push final token
|
|
76
|
+
if (currentToken) {
|
|
77
|
+
tokens.push({
|
|
78
|
+
text: currentToken,
|
|
79
|
+
startIndex: tokenStart,
|
|
80
|
+
endIndex: charIndex,
|
|
81
|
+
isWhitespace: inWhitespace,
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Build chunks using word wrapping
|
|
86
|
+
let currentChunk = "";
|
|
87
|
+
let currentWidth = 0;
|
|
88
|
+
let chunkStartIndex = 0;
|
|
89
|
+
let atLineStart = true; // Track if we're at the start of a line (for skipping whitespace)
|
|
90
|
+
|
|
91
|
+
for (const token of tokens) {
|
|
92
|
+
const tokenWidth = visibleWidth(token.text);
|
|
93
|
+
|
|
94
|
+
// Skip leading whitespace at line start
|
|
95
|
+
if (atLineStart && token.isWhitespace) {
|
|
96
|
+
chunkStartIndex = token.endIndex;
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
atLineStart = false;
|
|
100
|
+
|
|
101
|
+
// If this single token is wider than maxWidth, we need to break it
|
|
102
|
+
if (tokenWidth > maxWidth) {
|
|
103
|
+
// First, push any accumulated chunk
|
|
104
|
+
if (currentChunk) {
|
|
105
|
+
chunks.push({
|
|
106
|
+
text: currentChunk,
|
|
107
|
+
startIndex: chunkStartIndex,
|
|
108
|
+
endIndex: token.startIndex,
|
|
109
|
+
});
|
|
110
|
+
currentChunk = "";
|
|
111
|
+
currentWidth = 0;
|
|
112
|
+
chunkStartIndex = token.startIndex;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Break the long token by grapheme
|
|
116
|
+
let tokenChunk = "";
|
|
117
|
+
let tokenChunkWidth = 0;
|
|
118
|
+
let tokenChunkStart = token.startIndex;
|
|
119
|
+
let tokenCharIndex = token.startIndex;
|
|
120
|
+
|
|
121
|
+
for (const seg of segmenter.segment(token.text)) {
|
|
122
|
+
const grapheme = seg.segment;
|
|
123
|
+
const graphemeWidth = visibleWidth(grapheme);
|
|
124
|
+
|
|
125
|
+
if (tokenChunkWidth + graphemeWidth > maxWidth && tokenChunk) {
|
|
126
|
+
chunks.push({
|
|
127
|
+
text: tokenChunk,
|
|
128
|
+
startIndex: tokenChunkStart,
|
|
129
|
+
endIndex: tokenCharIndex,
|
|
130
|
+
});
|
|
131
|
+
tokenChunk = grapheme;
|
|
132
|
+
tokenChunkWidth = graphemeWidth;
|
|
133
|
+
tokenChunkStart = tokenCharIndex;
|
|
134
|
+
} else {
|
|
135
|
+
tokenChunk += grapheme;
|
|
136
|
+
tokenChunkWidth += graphemeWidth;
|
|
137
|
+
}
|
|
138
|
+
tokenCharIndex += grapheme.length;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Keep remainder as start of next chunk
|
|
142
|
+
if (tokenChunk) {
|
|
143
|
+
currentChunk = tokenChunk;
|
|
144
|
+
currentWidth = tokenChunkWidth;
|
|
145
|
+
chunkStartIndex = tokenChunkStart;
|
|
146
|
+
}
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Check if adding this token would exceed width
|
|
151
|
+
if (currentWidth + tokenWidth > maxWidth) {
|
|
152
|
+
// Push current chunk (trimming trailing whitespace for display)
|
|
153
|
+
const trimmedChunk = currentChunk.trimEnd();
|
|
154
|
+
if (trimmedChunk || chunks.length === 0) {
|
|
155
|
+
chunks.push({
|
|
156
|
+
text: trimmedChunk,
|
|
157
|
+
startIndex: chunkStartIndex,
|
|
158
|
+
endIndex: chunkStartIndex + currentChunk.length,
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Start new line - skip leading whitespace
|
|
163
|
+
atLineStart = true;
|
|
164
|
+
if (token.isWhitespace) {
|
|
165
|
+
currentChunk = "";
|
|
166
|
+
currentWidth = 0;
|
|
167
|
+
chunkStartIndex = token.endIndex;
|
|
168
|
+
} else {
|
|
169
|
+
currentChunk = token.text;
|
|
170
|
+
currentWidth = tokenWidth;
|
|
171
|
+
chunkStartIndex = token.startIndex;
|
|
172
|
+
atLineStart = false;
|
|
173
|
+
}
|
|
174
|
+
} else {
|
|
175
|
+
// Add token to current chunk
|
|
176
|
+
currentChunk += token.text;
|
|
177
|
+
currentWidth += tokenWidth;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Push final chunk
|
|
182
|
+
if (currentChunk) {
|
|
183
|
+
chunks.push({
|
|
184
|
+
text: currentChunk,
|
|
185
|
+
startIndex: chunkStartIndex,
|
|
186
|
+
endIndex: line.length,
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return chunks.length > 0 ? chunks : [{ text: "", startIndex: 0, endIndex: 0 }];
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Kitty CSI-u sequences for printable keys, including optional shifted/base codepoints and text field.
|
|
194
|
+
const KITTY_CSI_U_REGEX = /^\x1b\[(\d+)(?::(\d*))?(?::(\d+))?(?:;(\d+))?(?::(\d+))?(?:;([\d:]*))?u$/;
|
|
195
|
+
const KITTY_MOD_SHIFT = 1;
|
|
196
|
+
const KITTY_MOD_ALT = 2;
|
|
197
|
+
const KITTY_MOD_CTRL = 4;
|
|
198
|
+
|
|
199
|
+
// Decode a printable CSI-u sequence, preferring the shifted key when present.
|
|
200
|
+
function decodeKittyPrintable(data: string): string | undefined {
|
|
201
|
+
const match = data.match(KITTY_CSI_U_REGEX);
|
|
202
|
+
if (!match) return undefined;
|
|
203
|
+
|
|
204
|
+
// CSI-u groups: <codepoint>[:<shifted>[:<base>]];<mod>u
|
|
205
|
+
const codepoint = Number.parseInt(match[1] ?? "", 10);
|
|
206
|
+
if (!Number.isFinite(codepoint)) return undefined;
|
|
207
|
+
|
|
208
|
+
const shiftedKey = match[2] && match[2].length > 0 ? Number.parseInt(match[2], 10) : undefined;
|
|
209
|
+
const modValue = match[4] ? Number.parseInt(match[4], 10) : 1;
|
|
210
|
+
// Modifiers are 1-indexed in CSI-u; normalize to our bitmask.
|
|
211
|
+
const modifier = Number.isFinite(modValue) ? modValue - 1 : 0;
|
|
212
|
+
|
|
213
|
+
// Ignore CSI-u sequences used for Alt/Ctrl shortcuts.
|
|
214
|
+
if (modifier & (KITTY_MOD_ALT | KITTY_MOD_CTRL)) return undefined;
|
|
215
|
+
|
|
216
|
+
const textField = match[6];
|
|
217
|
+
if (textField && textField.length > 0) {
|
|
218
|
+
const codepoints = textField
|
|
219
|
+
.split(":")
|
|
220
|
+
.filter(Boolean)
|
|
221
|
+
.map(value => Number.parseInt(value, 10))
|
|
222
|
+
.filter(value => Number.isFinite(value) && value >= 32);
|
|
223
|
+
if (codepoints.length > 0) {
|
|
224
|
+
try {
|
|
225
|
+
return String.fromCodePoint(...codepoints);
|
|
226
|
+
} catch {
|
|
227
|
+
return undefined;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Prefer the shifted keycode when Shift is held.
|
|
233
|
+
let effectiveCodepoint = codepoint;
|
|
234
|
+
if (modifier & KITTY_MOD_SHIFT && typeof shiftedKey === "number") {
|
|
235
|
+
effectiveCodepoint = shiftedKey;
|
|
236
|
+
}
|
|
237
|
+
if (effectiveCodepoint >= 0xe000 && effectiveCodepoint <= 0xf8ff) {
|
|
238
|
+
return undefined;
|
|
239
|
+
}
|
|
240
|
+
// Drop control characters or invalid codepoints.
|
|
241
|
+
if (!Number.isFinite(effectiveCodepoint) || effectiveCodepoint < 32) return undefined;
|
|
242
|
+
|
|
243
|
+
try {
|
|
244
|
+
return String.fromCodePoint(effectiveCodepoint);
|
|
245
|
+
} catch {
|
|
246
|
+
return undefined;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
const DEFAULT_PAGE_SCROLL_LINES = 10;
|
|
250
|
+
|
|
251
|
+
interface EditorState {
|
|
252
|
+
lines: string[];
|
|
253
|
+
cursorLine: number;
|
|
254
|
+
cursorCol: number;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
interface LayoutLine {
|
|
258
|
+
text: string;
|
|
259
|
+
hasCursor: boolean;
|
|
260
|
+
cursorPos?: number;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
export interface EditorTheme {
|
|
264
|
+
borderColor: (str: string) => string;
|
|
265
|
+
selectList: SelectListTheme;
|
|
266
|
+
symbols: SymbolTheme;
|
|
267
|
+
editorPaddingX?: number;
|
|
268
|
+
/** Style function for inline hint/ghost text (dim text after cursor) */
|
|
269
|
+
hintStyle?: (text: string) => string;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
export interface EditorTopBorder {
|
|
273
|
+
/** The status content (already styled) */
|
|
274
|
+
content: string;
|
|
275
|
+
/** Visible width of the content */
|
|
276
|
+
width: number;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
interface HistoryEntry {
|
|
280
|
+
prompt: string;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
interface HistoryStorage {
|
|
284
|
+
add(prompt: string, cwd?: string): void;
|
|
285
|
+
getRecent(limit: number): HistoryEntry[];
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
type HistoryCursorAnchor = "start" | "end";
|
|
289
|
+
|
|
290
|
+
export class Editor implements Component, Focusable {
|
|
291
|
+
#state: EditorState = {
|
|
292
|
+
lines: [""],
|
|
293
|
+
cursorLine: 0,
|
|
294
|
+
cursorCol: 0,
|
|
295
|
+
};
|
|
296
|
+
|
|
297
|
+
/** Focusable interface - set by TUI when focus changes */
|
|
298
|
+
focused: boolean = false;
|
|
299
|
+
|
|
300
|
+
#theme: EditorTheme;
|
|
301
|
+
#useTerminalCursor = false;
|
|
302
|
+
|
|
303
|
+
/** When set, replaces the normal cursor glyph at end-of-text with this ANSI-styled string. */
|
|
304
|
+
cursorOverride: string | undefined;
|
|
305
|
+
/** Display width of the cursorOverride glyph (needed because override may contain ANSI escapes). */
|
|
306
|
+
cursorOverrideWidth: number | undefined;
|
|
307
|
+
|
|
308
|
+
// Store last layout width for cursor navigation
|
|
309
|
+
#lastLayoutWidth: number = 80;
|
|
310
|
+
#paddingXOverride: number | undefined;
|
|
311
|
+
#maxHeight?: number;
|
|
312
|
+
#scrollOffset: number = 0;
|
|
313
|
+
|
|
314
|
+
// Emacs-style kill ring
|
|
315
|
+
#killRing = new KillRing();
|
|
316
|
+
#lastAction: "kill" | "yank" | null = null;
|
|
317
|
+
|
|
318
|
+
// Character jump mode
|
|
319
|
+
#jumpMode: "forward" | "backward" | null = null;
|
|
320
|
+
|
|
321
|
+
// Preferred visual column for vertical cursor movement (sticky column)
|
|
322
|
+
#preferredVisualCol: number | null = null;
|
|
323
|
+
|
|
324
|
+
// Border color (can be changed dynamically)
|
|
325
|
+
borderColor: (str: string) => string;
|
|
326
|
+
|
|
327
|
+
// Autocomplete support
|
|
328
|
+
#autocompleteProvider?: AutocompleteProvider;
|
|
329
|
+
#autocompleteList?: SelectList;
|
|
330
|
+
#autocompleteState: "regular" | "force" | null = null;
|
|
331
|
+
#autocompletePrefix: string = "";
|
|
332
|
+
#autocompleteRequestId: number = 0;
|
|
333
|
+
#autocompleteMaxVisible: number = 5;
|
|
334
|
+
onAutocompleteUpdate?: () => void;
|
|
335
|
+
|
|
336
|
+
// Paste tracking for large pastes
|
|
337
|
+
#pastes: Map<number, string> = new Map();
|
|
338
|
+
#pasteCounter: number = 0;
|
|
339
|
+
|
|
340
|
+
// Bracketed paste mode buffering
|
|
341
|
+
#pasteBuffer: string = "";
|
|
342
|
+
#isInPaste: boolean = false;
|
|
343
|
+
|
|
344
|
+
// Prompt history for up/down navigation
|
|
345
|
+
#history: string[] = [];
|
|
346
|
+
#historyIndex: number = -1; // -1 = not browsing, 0 = most recent, 1 = older, etc.
|
|
347
|
+
#historyStorage?: HistoryStorage;
|
|
348
|
+
|
|
349
|
+
// Undo stack for editor state changes
|
|
350
|
+
#undoStack: EditorState[] = [];
|
|
351
|
+
#suspendUndo = false;
|
|
352
|
+
|
|
353
|
+
// Debounce timer for autocomplete updates
|
|
354
|
+
#autocompleteTimeout?: NodeJS.Timeout;
|
|
355
|
+
|
|
356
|
+
onSubmit?: (text: string) => void;
|
|
357
|
+
onAltEnter?: (text: string) => void;
|
|
358
|
+
onChange?: (text: string) => void;
|
|
359
|
+
onAutocompleteCancel?: () => void;
|
|
360
|
+
disableSubmit: boolean = false;
|
|
361
|
+
|
|
362
|
+
// Custom top border (for status line integration)
|
|
363
|
+
#topBorderContent?: EditorTopBorder;
|
|
364
|
+
|
|
365
|
+
constructor(theme: EditorTheme) {
|
|
366
|
+
this.#theme = theme;
|
|
367
|
+
this.borderColor = theme.borderColor;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
setAutocompleteProvider(provider: AutocompleteProvider): void {
|
|
371
|
+
this.#autocompleteProvider = provider;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Set custom content for the top border (e.g., status line).
|
|
376
|
+
* Pass undefined to use the default plain border.
|
|
377
|
+
*/
|
|
378
|
+
setTopBorder(content: EditorTopBorder | undefined): void {
|
|
379
|
+
this.#topBorderContent = content;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* Use the real terminal cursor instead of rendering a cursor glyph.
|
|
384
|
+
*/
|
|
385
|
+
setUseTerminalCursor(useTerminalCursor: boolean): void {
|
|
386
|
+
this.#useTerminalCursor = useTerminalCursor;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
getUseTerminalCursor(): boolean {
|
|
390
|
+
return this.#useTerminalCursor;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
setMaxHeight(maxHeight: number | undefined): void {
|
|
394
|
+
if (this.#maxHeight === maxHeight) return;
|
|
395
|
+
this.#maxHeight = maxHeight;
|
|
396
|
+
// Don't reset scrollOffset — #updateScrollOffset will clamp it on next render
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
setPaddingX(paddingX: number): void {
|
|
400
|
+
this.#paddingXOverride = Math.max(0, paddingX);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
getAutocompleteMaxVisible(): number {
|
|
404
|
+
return this.#autocompleteMaxVisible;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
setAutocompleteMaxVisible(maxVisible: number): void {
|
|
408
|
+
const newMaxVisible = Number.isFinite(maxVisible) ? Math.max(3, Math.min(20, Math.floor(maxVisible))) : 5;
|
|
409
|
+
if (this.#autocompleteMaxVisible !== newMaxVisible) {
|
|
410
|
+
this.#autocompleteMaxVisible = newMaxVisible;
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
setHistoryStorage(storage: HistoryStorage): void {
|
|
415
|
+
this.#historyStorage = storage;
|
|
416
|
+
const recent = storage.getRecent(100);
|
|
417
|
+
this.#history = recent.map(entry => entry.prompt);
|
|
418
|
+
this.#historyIndex = -1;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* Add a prompt to history for up/down arrow navigation.
|
|
423
|
+
* Called after successful submission.
|
|
424
|
+
*/
|
|
425
|
+
addToHistory(text: string): void {
|
|
426
|
+
const trimmed = text.trim();
|
|
427
|
+
if (!trimmed) return;
|
|
428
|
+
// Don't add consecutive duplicates
|
|
429
|
+
if (this.#history.length > 0 && this.#history[0] === trimmed) return;
|
|
430
|
+
this.#history.unshift(trimmed);
|
|
431
|
+
// Limit history size
|
|
432
|
+
if (this.#history.length > 100) {
|
|
433
|
+
this.#history.pop();
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
this.#historyStorage?.add(trimmed, getProjectDir());
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
#isEditorEmpty(): boolean {
|
|
440
|
+
return this.#state.lines.length === 1 && this.#state.lines[0] === "";
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
#isOnFirstVisualLine(): boolean {
|
|
444
|
+
const visualLines = this.#buildVisualLineMap(this.#lastLayoutWidth);
|
|
445
|
+
const currentVisualLine = this.#findCurrentVisualLine(visualLines);
|
|
446
|
+
return currentVisualLine === 0;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
#isOnLastVisualLine(): boolean {
|
|
450
|
+
const visualLines = this.#buildVisualLineMap(this.#lastLayoutWidth);
|
|
451
|
+
const currentVisualLine = this.#findCurrentVisualLine(visualLines);
|
|
452
|
+
return currentVisualLine === visualLines.length - 1;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
#navigateHistory(direction: 1 | -1): void {
|
|
456
|
+
this.#resetKillSequence();
|
|
457
|
+
if (this.#history.length === 0) return;
|
|
458
|
+
const newIndex = this.#historyIndex - direction; // Up(-1) increases index, Down(1) decreases
|
|
459
|
+
if (newIndex < -1 || newIndex >= this.#history.length) return;
|
|
460
|
+
this.#historyIndex = newIndex;
|
|
461
|
+
if (this.#historyIndex === -1) {
|
|
462
|
+
// Returned to "current" state - clear editor
|
|
463
|
+
this.#setTextInternal("", "end");
|
|
464
|
+
} else {
|
|
465
|
+
const cursorAnchor: HistoryCursorAnchor = direction === -1 ? "start" : "end";
|
|
466
|
+
this.#setTextInternal(this.#history[this.#historyIndex] || "", cursorAnchor);
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
/** Internal setText that doesn't reset history state - used by navigateHistory */
|
|
470
|
+
#setTextInternal(text: string, cursorAnchor: HistoryCursorAnchor = "end"): void {
|
|
471
|
+
this.#undoStack.length = 0;
|
|
472
|
+
const lines = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n");
|
|
473
|
+
this.#state.lines = lines.length === 0 ? [""] : lines;
|
|
474
|
+
if (cursorAnchor === "start") {
|
|
475
|
+
this.#state.cursorLine = 0;
|
|
476
|
+
this.#setCursorCol(0);
|
|
477
|
+
} else {
|
|
478
|
+
this.#state.cursorLine = this.#state.lines.length - 1;
|
|
479
|
+
this.#setCursorCol(this.#state.lines[this.#state.cursorLine]?.length || 0);
|
|
480
|
+
}
|
|
481
|
+
if (this.onChange) {
|
|
482
|
+
this.onChange(this.getText());
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
invalidate(): void {
|
|
487
|
+
// No cached state to invalidate currently
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
#getEditorPaddingX(): number {
|
|
491
|
+
const padding = this.#paddingXOverride ?? this.#theme.editorPaddingX ?? 2;
|
|
492
|
+
return Math.max(0, padding);
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
#getContentWidth(width: number, paddingX: number): number {
|
|
496
|
+
return Math.max(0, width - 2 * (paddingX + 1));
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
#getLayoutWidth(width: number, paddingX: number): number {
|
|
500
|
+
const contentWidth = this.#getContentWidth(width, paddingX);
|
|
501
|
+
return Math.max(1, contentWidth - (paddingX === 0 ? 1 : 0));
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
#getVisibleContentHeight(contentLines: number): number {
|
|
505
|
+
if (this.#maxHeight === undefined) return contentLines;
|
|
506
|
+
return Math.max(1, this.#maxHeight - 2);
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
#getPageScrollStep(totalVisualLines: number): number {
|
|
510
|
+
const visibleHeight =
|
|
511
|
+
this.#maxHeight === undefined ? DEFAULT_PAGE_SCROLL_LINES : this.#getVisibleContentHeight(totalVisualLines);
|
|
512
|
+
return Math.max(1, visibleHeight - 1);
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
#updateScrollOffset(layoutWidth: number, layoutLines: LayoutLine[], visibleHeight: number): void {
|
|
516
|
+
if (layoutLines.length <= visibleHeight) {
|
|
517
|
+
this.#scrollOffset = 0;
|
|
518
|
+
return;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
const visualLines = this.#buildVisualLineMap(layoutWidth);
|
|
522
|
+
const cursorLine = this.#findCurrentVisualLine(visualLines);
|
|
523
|
+
if (cursorLine < this.#scrollOffset) {
|
|
524
|
+
this.#scrollOffset = cursorLine;
|
|
525
|
+
} else if (cursorLine >= this.#scrollOffset + visibleHeight) {
|
|
526
|
+
this.#scrollOffset = cursorLine - visibleHeight + 1;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
const maxOffset = Math.max(0, layoutLines.length - visibleHeight);
|
|
530
|
+
this.#scrollOffset = Math.min(this.#scrollOffset, maxOffset);
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
render(width: number): string[] {
|
|
534
|
+
const paddingX = this.#getEditorPaddingX();
|
|
535
|
+
const contentAreaWidth = this.#getContentWidth(width, paddingX);
|
|
536
|
+
const layoutWidth = this.#getLayoutWidth(width, paddingX);
|
|
537
|
+
this.#lastLayoutWidth = layoutWidth;
|
|
538
|
+
|
|
539
|
+
// Box-drawing characters for rounded corners
|
|
540
|
+
const box = this.#theme.symbols.boxRound;
|
|
541
|
+
const borderWidth = paddingX + 1;
|
|
542
|
+
const topLeft = this.borderColor(`${box.topLeft}${box.horizontal.repeat(paddingX)}`);
|
|
543
|
+
const topRight = this.borderColor(`${box.horizontal.repeat(paddingX)}${box.topRight}`);
|
|
544
|
+
const bottomLeft = this.borderColor(`${box.bottomLeft}${box.horizontal}${padding(Math.max(0, paddingX - 1))}`);
|
|
545
|
+
const horizontal = this.borderColor(box.horizontal);
|
|
546
|
+
|
|
547
|
+
// Layout the text
|
|
548
|
+
const layoutLines = this.#layoutText(layoutWidth);
|
|
549
|
+
const visibleContentHeight = this.#getVisibleContentHeight(layoutLines.length);
|
|
550
|
+
this.#updateScrollOffset(layoutWidth, layoutLines, visibleContentHeight);
|
|
551
|
+
const visibleLayoutLines = layoutLines.slice(this.#scrollOffset, this.#scrollOffset + visibleContentHeight);
|
|
552
|
+
|
|
553
|
+
const result: string[] = [];
|
|
554
|
+
|
|
555
|
+
// Render top border: ╭─ [status content] ────────────────╮
|
|
556
|
+
const topFillWidth = width - borderWidth * 2;
|
|
557
|
+
if (this.#topBorderContent) {
|
|
558
|
+
const { content, width: statusWidth } = this.#topBorderContent;
|
|
559
|
+
if (statusWidth <= topFillWidth) {
|
|
560
|
+
// Status fits - add fill after it
|
|
561
|
+
const fillWidth = topFillWidth - statusWidth;
|
|
562
|
+
result.push(topLeft + content + this.borderColor(box.horizontal.repeat(fillWidth)) + topRight);
|
|
563
|
+
} else {
|
|
564
|
+
// Status too long - truncate it
|
|
565
|
+
const truncated = truncateToWidth(content, topFillWidth - 1);
|
|
566
|
+
const truncatedWidth = visibleWidth(truncated);
|
|
567
|
+
const fillWidth = Math.max(0, topFillWidth - truncatedWidth);
|
|
568
|
+
result.push(topLeft + truncated + this.borderColor(box.horizontal.repeat(fillWidth)) + topRight);
|
|
569
|
+
}
|
|
570
|
+
} else {
|
|
571
|
+
result.push(topLeft + horizontal.repeat(topFillWidth) + topRight);
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
// Render each layout line
|
|
575
|
+
// Emit hardware cursor marker only when focused and not showing autocomplete
|
|
576
|
+
const emitCursorMarker = this.focused && !this.#autocompleteState;
|
|
577
|
+
const lineContentWidth = contentAreaWidth;
|
|
578
|
+
|
|
579
|
+
// Compute inline hint text (dim ghost text after cursor)
|
|
580
|
+
const inlineHint = this.#getInlineHint();
|
|
581
|
+
const hintStyle = this.#theme.hintStyle ?? ((t: string) => `\x1b[2m${t}\x1b[0m`);
|
|
582
|
+
|
|
583
|
+
for (const layoutLine of visibleLayoutLines) {
|
|
584
|
+
let displayText = layoutLine.text;
|
|
585
|
+
let displayWidth = visibleWidth(layoutLine.text);
|
|
586
|
+
let cursorInPadding = false;
|
|
587
|
+
|
|
588
|
+
// Add cursor if this line has it
|
|
589
|
+
const hasCursor = layoutLine.hasCursor && layoutLine.cursorPos !== undefined;
|
|
590
|
+
const marker = emitCursorMarker ? CURSOR_MARKER : "";
|
|
591
|
+
|
|
592
|
+
if (hasCursor && this.#useTerminalCursor) {
|
|
593
|
+
if (marker) {
|
|
594
|
+
const before = displayText.slice(0, layoutLine.cursorPos);
|
|
595
|
+
const after = displayText.slice(layoutLine.cursorPos);
|
|
596
|
+
if (after.length === 0 && inlineHint) {
|
|
597
|
+
const hintText = hintStyle(truncateToWidth(inlineHint, Math.max(0, lineContentWidth - displayWidth)));
|
|
598
|
+
displayText = before + marker + hintText;
|
|
599
|
+
displayWidth += visibleWidth(inlineHint);
|
|
600
|
+
} else {
|
|
601
|
+
displayText = before + marker + after;
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
} else if (hasCursor && !this.#useTerminalCursor) {
|
|
605
|
+
const before = displayText.slice(0, layoutLine.cursorPos);
|
|
606
|
+
const after = displayText.slice(layoutLine.cursorPos);
|
|
607
|
+
|
|
608
|
+
if (after.length > 0) {
|
|
609
|
+
// Cursor is on a character (grapheme) - replace it with highlighted version
|
|
610
|
+
// Get the first grapheme from 'after'
|
|
611
|
+
const afterGraphemes = [...segmenter.segment(after)];
|
|
612
|
+
const firstGrapheme = afterGraphemes[0]?.segment || "";
|
|
613
|
+
const restAfter = after.slice(firstGrapheme.length);
|
|
614
|
+
const cursor = `\x1b[7m${firstGrapheme}\x1b[0m`;
|
|
615
|
+
displayText = before + marker + cursor + restAfter;
|
|
616
|
+
// displayWidth stays the same - we're replacing, not adding
|
|
617
|
+
} else if (this.cursorOverride) {
|
|
618
|
+
// Cursor override replaces the normal end-of-text cursor glyph
|
|
619
|
+
const overrideWidth = this.cursorOverrideWidth ?? 1;
|
|
620
|
+
if (inlineHint) {
|
|
621
|
+
const availWidth = Math.max(0, lineContentWidth - displayWidth - overrideWidth);
|
|
622
|
+
const hintText = hintStyle(truncateToWidth(inlineHint, availWidth));
|
|
623
|
+
displayText = before + marker + this.cursorOverride + hintText;
|
|
624
|
+
displayWidth += overrideWidth + Math.min(visibleWidth(inlineHint), availWidth);
|
|
625
|
+
} else {
|
|
626
|
+
displayText = before + marker + this.cursorOverride;
|
|
627
|
+
displayWidth += overrideWidth;
|
|
628
|
+
}
|
|
629
|
+
} else {
|
|
630
|
+
// Cursor is at the end - add thin cursor glyph
|
|
631
|
+
const cursorChar = this.#theme.symbols.inputCursor;
|
|
632
|
+
const cursor = `\x1b[5m${cursorChar}\x1b[0m`;
|
|
633
|
+
if (inlineHint) {
|
|
634
|
+
const availWidth = Math.max(0, lineContentWidth - displayWidth - visibleWidth(cursorChar));
|
|
635
|
+
const hintText = hintStyle(truncateToWidth(inlineHint, availWidth));
|
|
636
|
+
displayText = before + marker + cursor + hintText;
|
|
637
|
+
displayWidth += visibleWidth(cursorChar) + Math.min(visibleWidth(inlineHint), availWidth);
|
|
638
|
+
} else {
|
|
639
|
+
displayText = before + marker + cursor;
|
|
640
|
+
displayWidth += visibleWidth(cursorChar);
|
|
641
|
+
}
|
|
642
|
+
if (displayWidth > lineContentWidth && paddingX > 0) {
|
|
643
|
+
cursorInPadding = true;
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
// All lines have consistent borders based on padding
|
|
649
|
+
const isLastLine = layoutLine === visibleLayoutLines[visibleLayoutLines.length - 1];
|
|
650
|
+
const linePad = padding(Math.max(0, lineContentWidth - displayWidth));
|
|
651
|
+
|
|
652
|
+
const rightPaddingWidth = Math.max(0, paddingX - (cursorInPadding ? 1 : 0));
|
|
653
|
+
if (isLastLine) {
|
|
654
|
+
const bottomRightPadding = Math.max(0, paddingX - 1 - (cursorInPadding ? 1 : 0));
|
|
655
|
+
const bottomRightAdjusted = this.borderColor(
|
|
656
|
+
`${padding(bottomRightPadding)}${box.horizontal}${box.bottomRight}`,
|
|
657
|
+
);
|
|
658
|
+
result.push(`${bottomLeft}${displayText}${linePad}${bottomRightAdjusted}`);
|
|
659
|
+
} else {
|
|
660
|
+
const leftBorder = this.borderColor(`${box.vertical}${padding(paddingX)}`);
|
|
661
|
+
const rightBorder = this.borderColor(`${padding(rightPaddingWidth)}${box.vertical}`);
|
|
662
|
+
result.push(leftBorder + displayText + linePad + rightBorder);
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
// Add autocomplete list if active
|
|
667
|
+
if (this.#autocompleteState && this.#autocompleteList) {
|
|
668
|
+
const autocompleteResult = this.#autocompleteList.render(width);
|
|
669
|
+
result.push(...autocompleteResult);
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
return result;
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
handleInput(data: string): void {
|
|
676
|
+
const kb = getEditorKeybindings();
|
|
677
|
+
|
|
678
|
+
// Handle character jump mode (awaiting next character to jump to)
|
|
679
|
+
if (this.#jumpMode !== null) {
|
|
680
|
+
// Cancel if the hotkey is pressed again
|
|
681
|
+
if (kb.matches(data, "jumpForward") || kb.matches(data, "jumpBackward")) {
|
|
682
|
+
this.#jumpMode = null;
|
|
683
|
+
return;
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
if (data.charCodeAt(0) >= 32) {
|
|
687
|
+
// Printable character - perform the jump
|
|
688
|
+
const direction = this.#jumpMode;
|
|
689
|
+
this.#jumpMode = null;
|
|
690
|
+
this.#jumpToChar(data, direction);
|
|
691
|
+
return;
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
// Control character - cancel and fall through to normal handling
|
|
695
|
+
this.#jumpMode = null;
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
// Handle bracketed paste mode
|
|
699
|
+
// Start of paste: \x1b[200~
|
|
700
|
+
// End of paste: \x1b[201~
|
|
701
|
+
|
|
702
|
+
// Check if we're starting a bracketed paste
|
|
703
|
+
if (data.includes("\x1b[200~")) {
|
|
704
|
+
this.#isInPaste = true;
|
|
705
|
+
this.#pasteBuffer = "";
|
|
706
|
+
// Remove the start marker and keep the rest
|
|
707
|
+
data = data.replace("\x1b[200~", "");
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
// If we're in a paste, buffer the data
|
|
711
|
+
if (this.#isInPaste) {
|
|
712
|
+
// Append data to buffer first (end marker could be split across chunks)
|
|
713
|
+
this.#pasteBuffer += data;
|
|
714
|
+
|
|
715
|
+
// Check if the accumulated buffer contains the end marker
|
|
716
|
+
const endIndex = this.#pasteBuffer.indexOf("\x1b[201~");
|
|
717
|
+
if (endIndex !== -1) {
|
|
718
|
+
// Extract content before the end marker
|
|
719
|
+
const pasteContent = this.#pasteBuffer.substring(0, endIndex);
|
|
720
|
+
|
|
721
|
+
// Process the complete paste
|
|
722
|
+
this.#handlePaste(pasteContent);
|
|
723
|
+
|
|
724
|
+
// Reset paste state
|
|
725
|
+
this.#isInPaste = false;
|
|
726
|
+
|
|
727
|
+
// Process any remaining data after the end marker
|
|
728
|
+
const remaining = this.#pasteBuffer.substring(endIndex + 6); // 6 = length of \x1b[201~
|
|
729
|
+
this.#pasteBuffer = "";
|
|
730
|
+
|
|
731
|
+
if (remaining.length > 0) {
|
|
732
|
+
this.handleInput(remaining);
|
|
733
|
+
}
|
|
734
|
+
return;
|
|
735
|
+
} else {
|
|
736
|
+
// Still accumulating, wait for more data
|
|
737
|
+
return;
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
// Handle special key combinations first
|
|
742
|
+
|
|
743
|
+
// Ctrl+C - Exit (let parent handle this)
|
|
744
|
+
if (matchesKey(data, "ctrl+c")) {
|
|
745
|
+
return;
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
// Ctrl+- / Ctrl+_ - Undo last edit
|
|
749
|
+
if (matchesKey(data, "ctrl+-") || matchesKey(data, "ctrl+_")) {
|
|
750
|
+
this.#applyUndo();
|
|
751
|
+
return;
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
// Handle autocomplete special keys first (but don't block other input)
|
|
755
|
+
if (this.#autocompleteState && this.#autocompleteList) {
|
|
756
|
+
// Escape - cancel autocomplete
|
|
757
|
+
if (matchesKey(data, "escape") || matchesKey(data, "esc")) {
|
|
758
|
+
this.#cancelAutocomplete(true);
|
|
759
|
+
return;
|
|
760
|
+
}
|
|
761
|
+
// Let the autocomplete list handle navigation and selection
|
|
762
|
+
else if (
|
|
763
|
+
matchesKey(data, "up") ||
|
|
764
|
+
matchesKey(data, "down") ||
|
|
765
|
+
matchesKey(data, "pageUp") ||
|
|
766
|
+
matchesKey(data, "pageDown") ||
|
|
767
|
+
matchesKey(data, "enter") ||
|
|
768
|
+
matchesKey(data, "return") ||
|
|
769
|
+
data === "\n" ||
|
|
770
|
+
matchesKey(data, "tab")
|
|
771
|
+
) {
|
|
772
|
+
// Only pass navigation keys to the list, not Enter/Tab (we handle those directly)
|
|
773
|
+
if (
|
|
774
|
+
matchesKey(data, "up") ||
|
|
775
|
+
matchesKey(data, "down") ||
|
|
776
|
+
matchesKey(data, "pageUp") ||
|
|
777
|
+
matchesKey(data, "pageDown")
|
|
778
|
+
) {
|
|
779
|
+
this.#autocompleteList.handleInput(data);
|
|
780
|
+
this.onAutocompleteUpdate?.();
|
|
781
|
+
return;
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
// If Tab was pressed, always apply the selection
|
|
785
|
+
if (matchesKey(data, "tab")) {
|
|
786
|
+
const selected = this.#autocompleteList.getSelectedItem();
|
|
787
|
+
if (selected && this.#autocompleteProvider) {
|
|
788
|
+
const result = this.#autocompleteProvider.applyCompletion(
|
|
789
|
+
this.#state.lines,
|
|
790
|
+
this.#state.cursorLine,
|
|
791
|
+
this.#state.cursorCol,
|
|
792
|
+
selected,
|
|
793
|
+
this.#autocompletePrefix,
|
|
794
|
+
);
|
|
795
|
+
|
|
796
|
+
this.#state.lines = result.lines;
|
|
797
|
+
this.#state.cursorLine = result.cursorLine;
|
|
798
|
+
this.#setCursorCol(result.cursorCol);
|
|
799
|
+
|
|
800
|
+
this.#cancelAutocomplete();
|
|
801
|
+
|
|
802
|
+
if (this.onChange) {
|
|
803
|
+
this.onChange(this.getText());
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
return;
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
// If Enter was pressed on a slash command, apply completion and submit
|
|
810
|
+
if (
|
|
811
|
+
(matchesKey(data, "enter") || matchesKey(data, "return") || data === "\n") &&
|
|
812
|
+
this.#autocompletePrefix.startsWith("/")
|
|
813
|
+
) {
|
|
814
|
+
// Check for stale autocomplete state due to debounce
|
|
815
|
+
const currentLine = this.#state.lines[this.#state.cursorLine] ?? "";
|
|
816
|
+
const currentTextBeforeCursor = currentLine.slice(0, this.#state.cursorCol);
|
|
817
|
+
if (currentTextBeforeCursor !== this.#autocompletePrefix) {
|
|
818
|
+
// Autocomplete is stale - cancel and fall through to normal submission
|
|
819
|
+
this.#cancelAutocomplete();
|
|
820
|
+
} else {
|
|
821
|
+
const selected = this.#autocompleteList.getSelectedItem();
|
|
822
|
+
if (selected && this.#autocompleteProvider) {
|
|
823
|
+
const result = this.#autocompleteProvider.applyCompletion(
|
|
824
|
+
this.#state.lines,
|
|
825
|
+
this.#state.cursorLine,
|
|
826
|
+
this.#state.cursorCol,
|
|
827
|
+
selected,
|
|
828
|
+
this.#autocompletePrefix,
|
|
829
|
+
);
|
|
830
|
+
|
|
831
|
+
this.#state.lines = result.lines;
|
|
832
|
+
this.#state.cursorLine = result.cursorLine;
|
|
833
|
+
this.#setCursorCol(result.cursorCol);
|
|
834
|
+
}
|
|
835
|
+
this.#cancelAutocomplete();
|
|
836
|
+
}
|
|
837
|
+
// Don't return - fall through to submission logic
|
|
838
|
+
}
|
|
839
|
+
// If Enter was pressed on a file path, apply completion
|
|
840
|
+
else if (matchesKey(data, "enter") || matchesKey(data, "return") || data === "\n") {
|
|
841
|
+
const selected = this.#autocompleteList.getSelectedItem();
|
|
842
|
+
if (selected && this.#autocompleteProvider) {
|
|
843
|
+
const result = this.#autocompleteProvider.applyCompletion(
|
|
844
|
+
this.#state.lines,
|
|
845
|
+
this.#state.cursorLine,
|
|
846
|
+
this.#state.cursorCol,
|
|
847
|
+
selected,
|
|
848
|
+
this.#autocompletePrefix,
|
|
849
|
+
);
|
|
850
|
+
|
|
851
|
+
this.#state.lines = result.lines;
|
|
852
|
+
this.#state.cursorLine = result.cursorLine;
|
|
853
|
+
this.#setCursorCol(result.cursorCol);
|
|
854
|
+
|
|
855
|
+
this.#cancelAutocomplete();
|
|
856
|
+
|
|
857
|
+
if (this.onChange) {
|
|
858
|
+
this.onChange(this.getText());
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
return;
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
// For other keys (like regular typing), DON'T return here
|
|
865
|
+
// Let them fall through to normal character handling
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
// Tab key - context-aware completion (but not when already autocompleting)
|
|
869
|
+
if (matchesKey(data, "tab") && !this.#autocompleteState) {
|
|
870
|
+
this.#handleTabCompletion();
|
|
871
|
+
return;
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
// Continue with rest of input handling
|
|
875
|
+
// Ctrl+K - Delete to end of line
|
|
876
|
+
if (matchesKey(data, "ctrl+k")) {
|
|
877
|
+
this.#deleteToEndOfLine();
|
|
878
|
+
}
|
|
879
|
+
// Ctrl+U - Delete to start of line
|
|
880
|
+
else if (matchesKey(data, "ctrl+u")) {
|
|
881
|
+
this.#deleteToStartOfLine();
|
|
882
|
+
}
|
|
883
|
+
// Ctrl+W - Delete word backwards
|
|
884
|
+
else if (matchesKey(data, "ctrl+w")) {
|
|
885
|
+
this.#deleteWordBackwards();
|
|
886
|
+
}
|
|
887
|
+
// Option/Alt+Backspace - Delete word backwards
|
|
888
|
+
else if (matchesKey(data, "alt+backspace")) {
|
|
889
|
+
this.#deleteWordBackwards();
|
|
890
|
+
}
|
|
891
|
+
// Option/Alt+D - Delete word forwards
|
|
892
|
+
else if (matchesKey(data, "alt+d") || matchesKey(data, "alt+delete")) {
|
|
893
|
+
this.#deleteWordForwards();
|
|
894
|
+
}
|
|
895
|
+
// Ctrl+Y - Yank from kill ring
|
|
896
|
+
else if (matchesKey(data, "ctrl+y")) {
|
|
897
|
+
this.#yankFromKillRing();
|
|
898
|
+
}
|
|
899
|
+
// Alt+Y - Yank-pop (cycle kill ring)
|
|
900
|
+
else if (matchesKey(data, "alt+y")) {
|
|
901
|
+
this.#yankPop();
|
|
902
|
+
}
|
|
903
|
+
// Ctrl+A - Move to start of line
|
|
904
|
+
else if (matchesKey(data, "ctrl+a")) {
|
|
905
|
+
this.#moveToLineStart();
|
|
906
|
+
}
|
|
907
|
+
// Ctrl+E - Move to end of line
|
|
908
|
+
else if (matchesKey(data, "ctrl+e")) {
|
|
909
|
+
this.#moveToLineEnd();
|
|
910
|
+
}
|
|
911
|
+
// Alt+Enter - special handler if callback exists, otherwise new line
|
|
912
|
+
else if (matchesKey(data, "alt+enter")) {
|
|
913
|
+
if (this.onAltEnter) {
|
|
914
|
+
this.onAltEnter(this.getText());
|
|
915
|
+
} else {
|
|
916
|
+
this.#addNewLine();
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
// New line
|
|
920
|
+
else if (
|
|
921
|
+
(data.charCodeAt(0) === 10 && data.length > 1) || // Ctrl+Enter with modifiers
|
|
922
|
+
data === "\x1b[13;5u" || // Ctrl+Enter (Kitty protocol)
|
|
923
|
+
data === "\x1b[27;5;13~" || // Ctrl+Enter (legacy format)
|
|
924
|
+
data === "\x1b\r" || // Option+Enter in some terminals (legacy)
|
|
925
|
+
data === "\x1b[13;2~" || // Shift+Enter in some terminals (legacy format)
|
|
926
|
+
matchesKey(data, "shift+enter") || // Shift+Enter (Kitty protocol, handles lock bits)
|
|
927
|
+
(data.length > 1 && data.includes("\x1b") && data.includes("\r")) ||
|
|
928
|
+
(data === "\n" && data.length === 1) // Shift+Enter from iTerm2 mapping
|
|
929
|
+
) {
|
|
930
|
+
if (this.#shouldSubmitOnBackslashEnter(data, kb)) {
|
|
931
|
+
this.#handleBackspace();
|
|
932
|
+
this.#submitValue();
|
|
933
|
+
return;
|
|
934
|
+
}
|
|
935
|
+
this.#addNewLine();
|
|
936
|
+
}
|
|
937
|
+
// Plain Enter - submit (handles both legacy \r and Kitty protocol with lock bits)
|
|
938
|
+
else if (matchesKey(data, "enter") || matchesKey(data, "return") || data === "\n") {
|
|
939
|
+
// If submit is disabled, do nothing
|
|
940
|
+
if (this.disableSubmit) {
|
|
941
|
+
return;
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
this.#submitValue();
|
|
945
|
+
}
|
|
946
|
+
// Backspace (including Shift+Backspace)
|
|
947
|
+
else if (matchesKey(data, "backspace") || matchesKey(data, "shift+backspace")) {
|
|
948
|
+
this.#handleBackspace();
|
|
949
|
+
}
|
|
950
|
+
// Line navigation shortcuts (Home/End keys)
|
|
951
|
+
else if (matchesKey(data, "home")) {
|
|
952
|
+
this.#moveToLineStart();
|
|
953
|
+
} else if (matchesKey(data, "end")) {
|
|
954
|
+
this.#moveToLineEnd();
|
|
955
|
+
}
|
|
956
|
+
// Page navigation (PageUp/PageDown)
|
|
957
|
+
else if (matchesKey(data, "pageUp")) {
|
|
958
|
+
if (this.#isEditorEmpty()) {
|
|
959
|
+
this.#navigateHistory(-1);
|
|
960
|
+
} else if (this.#historyIndex > -1 && this.#isOnFirstVisualLine()) {
|
|
961
|
+
this.#navigateHistory(-1);
|
|
962
|
+
} else {
|
|
963
|
+
this.#pageScroll(-1);
|
|
964
|
+
}
|
|
965
|
+
} else if (matchesKey(data, "pageDown")) {
|
|
966
|
+
if (this.#historyIndex > -1 && this.#isOnLastVisualLine()) {
|
|
967
|
+
this.#navigateHistory(1);
|
|
968
|
+
} else {
|
|
969
|
+
this.#pageScroll(1);
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
// Forward delete (Fn+Backspace or Delete key, including Shift+Delete)
|
|
973
|
+
else if (matchesKey(data, "delete") || matchesKey(data, "shift+delete")) {
|
|
974
|
+
this.#handleForwardDelete();
|
|
975
|
+
}
|
|
976
|
+
// Word navigation (Option/Alt + Arrow or Ctrl + Arrow)
|
|
977
|
+
else if (matchesKey(data, "alt+left") || matchesKey(data, "ctrl+left")) {
|
|
978
|
+
// Word left
|
|
979
|
+
this.#resetKillSequence();
|
|
980
|
+
this.#moveWordBackwards();
|
|
981
|
+
} else if (matchesKey(data, "alt+right") || matchesKey(data, "ctrl+right")) {
|
|
982
|
+
// Word right
|
|
983
|
+
this.#resetKillSequence();
|
|
984
|
+
this.#moveWordForwards();
|
|
985
|
+
}
|
|
986
|
+
// Arrow keys
|
|
987
|
+
else if (matchesKey(data, "up")) {
|
|
988
|
+
// Up - history navigation or cursor movement
|
|
989
|
+
if (this.#isEditorEmpty()) {
|
|
990
|
+
this.#navigateHistory(-1); // Start browsing history
|
|
991
|
+
} else if (this.#historyIndex > -1 && this.#isOnFirstVisualLine()) {
|
|
992
|
+
this.#navigateHistory(-1); // Navigate to older history entry
|
|
993
|
+
} else if (this.#isOnFirstVisualLine()) {
|
|
994
|
+
// Already at top - jump to start of line
|
|
995
|
+
this.#moveToLineStart();
|
|
996
|
+
} else {
|
|
997
|
+
this.#moveCursor(-1, 0); // Cursor movement (within text or history entry)
|
|
998
|
+
}
|
|
999
|
+
} else if (matchesKey(data, "down")) {
|
|
1000
|
+
// Down - history navigation or cursor movement
|
|
1001
|
+
if (this.#historyIndex > -1 && this.#isOnLastVisualLine()) {
|
|
1002
|
+
this.#navigateHistory(1); // Navigate to newer history entry or clear
|
|
1003
|
+
} else if (this.#isOnLastVisualLine()) {
|
|
1004
|
+
// Already at bottom - jump to end of line
|
|
1005
|
+
this.#moveToLineEnd();
|
|
1006
|
+
} else {
|
|
1007
|
+
this.#moveCursor(1, 0); // Cursor movement (within text or history entry)
|
|
1008
|
+
}
|
|
1009
|
+
} else if (matchesKey(data, "right")) {
|
|
1010
|
+
// Right
|
|
1011
|
+
this.#moveCursor(0, 1);
|
|
1012
|
+
} else if (matchesKey(data, "left")) {
|
|
1013
|
+
// Left
|
|
1014
|
+
this.#moveCursor(0, -1);
|
|
1015
|
+
}
|
|
1016
|
+
// Shift+Space - insert regular space (Kitty protocol sends escape sequence)
|
|
1017
|
+
else if (matchesKey(data, "shift+space")) {
|
|
1018
|
+
this.#insertCharacter(" ");
|
|
1019
|
+
}
|
|
1020
|
+
// Character jump mode triggers
|
|
1021
|
+
else if (kb.matches(data, "jumpForward")) {
|
|
1022
|
+
this.#jumpMode = "forward";
|
|
1023
|
+
} else if (kb.matches(data, "jumpBackward")) {
|
|
1024
|
+
this.#jumpMode = "backward";
|
|
1025
|
+
}
|
|
1026
|
+
// Kitty CSI-u printable characters (shifted symbols like @, ?, {, })
|
|
1027
|
+
else {
|
|
1028
|
+
const kittyChar = decodeKittyPrintable(data);
|
|
1029
|
+
if (kittyChar) {
|
|
1030
|
+
this.insertText(kittyChar);
|
|
1031
|
+
return;
|
|
1032
|
+
}
|
|
1033
|
+
// Regular characters (printable characters and unicode, but not control characters)
|
|
1034
|
+
if (data.charCodeAt(0) >= 32) {
|
|
1035
|
+
this.#insertCharacter(data);
|
|
1036
|
+
}
|
|
1037
|
+
}
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
#layoutText(contentWidth: number): LayoutLine[] {
|
|
1041
|
+
const layoutLines: LayoutLine[] = [];
|
|
1042
|
+
|
|
1043
|
+
if (this.#state.lines.length === 0 || (this.#state.lines.length === 1 && this.#state.lines[0] === "")) {
|
|
1044
|
+
// Empty editor
|
|
1045
|
+
layoutLines.push({
|
|
1046
|
+
text: "",
|
|
1047
|
+
hasCursor: true,
|
|
1048
|
+
cursorPos: 0,
|
|
1049
|
+
});
|
|
1050
|
+
return layoutLines;
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
// Process each logical line
|
|
1054
|
+
for (let i = 0; i < this.#state.lines.length; i++) {
|
|
1055
|
+
const line = this.#state.lines[i] || "";
|
|
1056
|
+
const isCurrentLine = i === this.#state.cursorLine;
|
|
1057
|
+
const lineVisibleWidth = visibleWidth(line);
|
|
1058
|
+
|
|
1059
|
+
if (lineVisibleWidth <= contentWidth) {
|
|
1060
|
+
// Line fits in one layout line
|
|
1061
|
+
if (isCurrentLine) {
|
|
1062
|
+
layoutLines.push({
|
|
1063
|
+
text: line,
|
|
1064
|
+
hasCursor: true,
|
|
1065
|
+
cursorPos: this.#state.cursorCol,
|
|
1066
|
+
});
|
|
1067
|
+
} else {
|
|
1068
|
+
layoutLines.push({
|
|
1069
|
+
text: line,
|
|
1070
|
+
hasCursor: false,
|
|
1071
|
+
});
|
|
1072
|
+
}
|
|
1073
|
+
} else {
|
|
1074
|
+
// Line needs wrapping - use word-aware wrapping
|
|
1075
|
+
const chunks = wordWrapLine(line, contentWidth);
|
|
1076
|
+
|
|
1077
|
+
for (let chunkIndex = 0; chunkIndex < chunks.length; chunkIndex++) {
|
|
1078
|
+
const chunk = chunks[chunkIndex];
|
|
1079
|
+
if (!chunk) continue;
|
|
1080
|
+
|
|
1081
|
+
const cursorPos = this.#state.cursorCol;
|
|
1082
|
+
const isLastChunk = chunkIndex === chunks.length - 1;
|
|
1083
|
+
|
|
1084
|
+
// Determine if cursor is in this chunk
|
|
1085
|
+
// For word-wrapped chunks, we need to handle the case where
|
|
1086
|
+
// cursor might be in trimmed whitespace at end of chunk
|
|
1087
|
+
let hasCursorInChunk = false;
|
|
1088
|
+
let adjustedCursorPos = 0;
|
|
1089
|
+
|
|
1090
|
+
if (isCurrentLine) {
|
|
1091
|
+
if (isLastChunk) {
|
|
1092
|
+
// Last chunk: cursor belongs here if >= startIndex
|
|
1093
|
+
hasCursorInChunk = cursorPos >= chunk.startIndex;
|
|
1094
|
+
adjustedCursorPos = cursorPos - chunk.startIndex;
|
|
1095
|
+
} else {
|
|
1096
|
+
// Non-last chunk: cursor belongs here if in range [startIndex, endIndex)
|
|
1097
|
+
// But we need to handle the visual position in the trimmed text
|
|
1098
|
+
hasCursorInChunk = cursorPos >= chunk.startIndex && cursorPos < chunk.endIndex;
|
|
1099
|
+
if (hasCursorInChunk) {
|
|
1100
|
+
adjustedCursorPos = cursorPos - chunk.startIndex;
|
|
1101
|
+
// Clamp to text length (in case cursor was in trimmed whitespace)
|
|
1102
|
+
if (adjustedCursorPos > chunk.text.length) {
|
|
1103
|
+
adjustedCursorPos = chunk.text.length;
|
|
1104
|
+
}
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
if (hasCursorInChunk) {
|
|
1110
|
+
layoutLines.push({
|
|
1111
|
+
text: chunk.text,
|
|
1112
|
+
hasCursor: true,
|
|
1113
|
+
cursorPos: adjustedCursorPos,
|
|
1114
|
+
});
|
|
1115
|
+
} else {
|
|
1116
|
+
layoutLines.push({
|
|
1117
|
+
text: chunk.text,
|
|
1118
|
+
hasCursor: false,
|
|
1119
|
+
});
|
|
1120
|
+
}
|
|
1121
|
+
}
|
|
1122
|
+
}
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
return layoutLines;
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
getText(): string {
|
|
1129
|
+
return this.#state.lines.join("\n");
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
/**
|
|
1133
|
+
* Get text with paste markers expanded to their actual content.
|
|
1134
|
+
* Use this when you need the full content (e.g., for external editor).
|
|
1135
|
+
*/
|
|
1136
|
+
getExpandedText(): string {
|
|
1137
|
+
let result = this.#state.lines.join("\n");
|
|
1138
|
+
for (const [pasteId, pasteContent] of this.#pastes) {
|
|
1139
|
+
const markerRegex = new RegExp(`\\[paste #${pasteId}( (\\+\\d+ lines|\\d+ chars))?\\]`, "g");
|
|
1140
|
+
result = result.replace(markerRegex, pasteContent);
|
|
1141
|
+
}
|
|
1142
|
+
return result;
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
getLines(): string[] {
|
|
1146
|
+
return [...this.#state.lines];
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
getCursor(): { line: number; col: number } {
|
|
1150
|
+
return { line: this.#state.cursorLine, col: this.#state.cursorCol };
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
setText(text: string): void {
|
|
1154
|
+
this.#historyIndex = -1; // Exit history browsing mode
|
|
1155
|
+
this.#resetKillSequence();
|
|
1156
|
+
this.#setTextInternal(text);
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
#exitHistoryForEditing(): void {
|
|
1160
|
+
if (this.#historyIndex === -1) return;
|
|
1161
|
+
if (this.#state.cursorLine === 0 && this.#state.cursorCol === 0) {
|
|
1162
|
+
this.#state.cursorLine = this.#state.lines.length - 1;
|
|
1163
|
+
const line = this.#state.lines[this.#state.cursorLine] || "";
|
|
1164
|
+
this.#setCursorCol(line.length);
|
|
1165
|
+
}
|
|
1166
|
+
this.#historyIndex = -1;
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
/** Insert text at the current cursor position */
|
|
1170
|
+
insertText(text: string): void {
|
|
1171
|
+
this.#exitHistoryForEditing();
|
|
1172
|
+
this.#resetKillSequence();
|
|
1173
|
+
this.#recordUndoState();
|
|
1174
|
+
|
|
1175
|
+
const line = this.#state.lines[this.#state.cursorLine] || "";
|
|
1176
|
+
const before = line.slice(0, this.#state.cursorCol);
|
|
1177
|
+
const after = line.slice(this.#state.cursorCol);
|
|
1178
|
+
|
|
1179
|
+
this.#state.lines[this.#state.cursorLine] = before + text + after;
|
|
1180
|
+
this.#setCursorCol(this.#state.cursorCol + text.length);
|
|
1181
|
+
|
|
1182
|
+
if (this.onChange) {
|
|
1183
|
+
this.onChange(this.getText());
|
|
1184
|
+
}
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
// All the editor methods from before...
|
|
1188
|
+
#insertCharacter(char: string): void {
|
|
1189
|
+
this.#exitHistoryForEditing();
|
|
1190
|
+
this.#resetKillSequence();
|
|
1191
|
+
this.#recordUndoState();
|
|
1192
|
+
|
|
1193
|
+
const line = this.#state.lines[this.#state.cursorLine] || "";
|
|
1194
|
+
|
|
1195
|
+
const before = line.slice(0, this.#state.cursorCol);
|
|
1196
|
+
const after = line.slice(this.#state.cursorCol);
|
|
1197
|
+
|
|
1198
|
+
this.#state.lines[this.#state.cursorLine] = before + char + after;
|
|
1199
|
+
this.#setCursorCol(this.#state.cursorCol + char.length);
|
|
1200
|
+
|
|
1201
|
+
if (this.onChange) {
|
|
1202
|
+
this.onChange(this.getText());
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
// Check if we should trigger or update autocomplete
|
|
1206
|
+
if (!this.#autocompleteState) {
|
|
1207
|
+
// Auto-trigger for "/" at the start of a line (slash commands)
|
|
1208
|
+
if (char === "/" && this.#isAtStartOfMessage()) {
|
|
1209
|
+
this.#tryTriggerAutocomplete();
|
|
1210
|
+
}
|
|
1211
|
+
// Auto-trigger for "@" file reference (fuzzy search)
|
|
1212
|
+
else if (char === "@") {
|
|
1213
|
+
const currentLine = this.#state.lines[this.#state.cursorLine] || "";
|
|
1214
|
+
const textBeforeCursor = currentLine.slice(0, this.#state.cursorCol);
|
|
1215
|
+
// Only trigger if @ is after whitespace or at start of line
|
|
1216
|
+
const charBeforeAt = textBeforeCursor[textBeforeCursor.length - 2];
|
|
1217
|
+
if (textBeforeCursor.length === 1 || charBeforeAt === " " || charBeforeAt === "\t") {
|
|
1218
|
+
this.#tryTriggerAutocomplete();
|
|
1219
|
+
}
|
|
1220
|
+
}
|
|
1221
|
+
// Also auto-trigger when typing letters/path chars in a slash command context
|
|
1222
|
+
else if (/[a-zA-Z0-9.\-_/]/.test(char)) {
|
|
1223
|
+
const currentLine = this.#state.lines[this.#state.cursorLine] || "";
|
|
1224
|
+
const textBeforeCursor = currentLine.slice(0, this.#state.cursorCol);
|
|
1225
|
+
// Check if we're in a slash command (with or without space for arguments)
|
|
1226
|
+
if (textBeforeCursor.trimStart().startsWith("/")) {
|
|
1227
|
+
this.#tryTriggerAutocomplete();
|
|
1228
|
+
}
|
|
1229
|
+
// Check if we're in an @ file reference context
|
|
1230
|
+
else if (textBeforeCursor.match(/(?:^|[\s])@[^\s]*$/)) {
|
|
1231
|
+
this.#tryTriggerAutocomplete();
|
|
1232
|
+
}
|
|
1233
|
+
}
|
|
1234
|
+
} else {
|
|
1235
|
+
this.#debouncedUpdateAutocomplete();
|
|
1236
|
+
}
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
#handlePaste(pastedText: string): void {
|
|
1240
|
+
this.#historyIndex = -1; // Exit history browsing mode
|
|
1241
|
+
this.#resetKillSequence();
|
|
1242
|
+
this.#recordUndoState();
|
|
1243
|
+
|
|
1244
|
+
this.#withUndoSuspended(() => {
|
|
1245
|
+
// Clean the pasted text
|
|
1246
|
+
const cleanText = pastedText.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
1247
|
+
|
|
1248
|
+
// Convert tabs to spaces (4 spaces per tab)
|
|
1249
|
+
const tabExpandedText = cleanText.replace(/\t/g, " ");
|
|
1250
|
+
|
|
1251
|
+
// Filter out non-printable characters except newlines
|
|
1252
|
+
let filteredText = tabExpandedText
|
|
1253
|
+
.split("")
|
|
1254
|
+
.filter(char => char === "\n" || char.charCodeAt(0) >= 32)
|
|
1255
|
+
.join("");
|
|
1256
|
+
|
|
1257
|
+
// If pasting a file path (starts with /, ~, or .) and the character before
|
|
1258
|
+
// the cursor is a word character, prepend a space for better readability
|
|
1259
|
+
if (/^[/~.]/.test(filteredText)) {
|
|
1260
|
+
const currentLine = this.#state.lines[this.#state.cursorLine] || "";
|
|
1261
|
+
const charBeforeCursor = this.#state.cursorCol > 0 ? currentLine[this.#state.cursorCol - 1] : "";
|
|
1262
|
+
if (charBeforeCursor && /\w/.test(charBeforeCursor)) {
|
|
1263
|
+
filteredText = ` ${filteredText}`;
|
|
1264
|
+
}
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1267
|
+
// Split into lines
|
|
1268
|
+
const pastedLines = filteredText.split("\n");
|
|
1269
|
+
|
|
1270
|
+
// Check if this is a large paste (> 10 lines or > 1000 characters)
|
|
1271
|
+
const totalChars = filteredText.length;
|
|
1272
|
+
if (pastedLines.length > 10 || totalChars > 1000) {
|
|
1273
|
+
// Store the paste and insert a marker
|
|
1274
|
+
this.#pasteCounter++;
|
|
1275
|
+
const pasteId = this.#pasteCounter;
|
|
1276
|
+
this.#pastes.set(pasteId, filteredText);
|
|
1277
|
+
|
|
1278
|
+
// Insert marker like "[paste #1 +123 lines]" or "[paste #1 1234 chars]"
|
|
1279
|
+
const marker =
|
|
1280
|
+
pastedLines.length > 10
|
|
1281
|
+
? `[paste #${pasteId} +${pastedLines.length} lines]`
|
|
1282
|
+
: `[paste #${pasteId} ${totalChars} chars]`;
|
|
1283
|
+
this.#insertTextAtCursor(marker);
|
|
1284
|
+
|
|
1285
|
+
return;
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
if (pastedLines.length === 1) {
|
|
1289
|
+
// Single line - insert character by character to trigger autocomplete
|
|
1290
|
+
for (const char of filteredText) {
|
|
1291
|
+
this.#insertCharacter(char);
|
|
1292
|
+
}
|
|
1293
|
+
return;
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1296
|
+
// Multi-line paste - use insertTextAtCursor for proper handling
|
|
1297
|
+
this.#insertTextAtCursor(filteredText);
|
|
1298
|
+
});
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
#addNewLine(): void {
|
|
1302
|
+
this.#historyIndex = -1; // Exit history browsing mode
|
|
1303
|
+
this.#resetKillSequence();
|
|
1304
|
+
this.#recordUndoState();
|
|
1305
|
+
|
|
1306
|
+
const currentLine = this.#state.lines[this.#state.cursorLine] || "";
|
|
1307
|
+
|
|
1308
|
+
const before = currentLine.slice(0, this.#state.cursorCol);
|
|
1309
|
+
const after = currentLine.slice(this.#state.cursorCol);
|
|
1310
|
+
|
|
1311
|
+
// Split current line
|
|
1312
|
+
this.#state.lines[this.#state.cursorLine] = before;
|
|
1313
|
+
this.#state.lines.splice(this.#state.cursorLine + 1, 0, after);
|
|
1314
|
+
|
|
1315
|
+
// Move cursor to start of new line
|
|
1316
|
+
this.#state.cursorLine++;
|
|
1317
|
+
this.#setCursorCol(0);
|
|
1318
|
+
|
|
1319
|
+
if (this.onChange) {
|
|
1320
|
+
this.onChange(this.getText());
|
|
1321
|
+
}
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
#shouldSubmitOnBackslashEnter(data: string, kb: EditorKeybindingsManager): boolean {
|
|
1325
|
+
if (this.disableSubmit) return false;
|
|
1326
|
+
if (!matchesKey(data, "enter")) return false;
|
|
1327
|
+
const submitKeys = kb.getKeys("submit");
|
|
1328
|
+
const hasShiftEnter = submitKeys.includes("shift+enter") || submitKeys.includes("shift+return");
|
|
1329
|
+
if (!hasShiftEnter) return false;
|
|
1330
|
+
|
|
1331
|
+
const currentLine = this.#state.lines[this.#state.cursorLine] || "";
|
|
1332
|
+
return this.#state.cursorCol > 0 && currentLine[this.#state.cursorCol - 1] === "\\";
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1335
|
+
#submitValue(): void {
|
|
1336
|
+
this.#resetKillSequence();
|
|
1337
|
+
|
|
1338
|
+
let result = this.#state.lines.join("\n").trim();
|
|
1339
|
+
for (const [pasteId, pasteContent] of this.#pastes) {
|
|
1340
|
+
const markerRegex = new RegExp(`\\[paste #${pasteId}( (\\+\\d+ lines|\\d+ chars))?\\]`, "g");
|
|
1341
|
+
result = result.replace(markerRegex, pasteContent);
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1344
|
+
this.#state = { lines: [""], cursorLine: 0, cursorCol: 0 };
|
|
1345
|
+
this.#pastes.clear();
|
|
1346
|
+
this.#pasteCounter = 0;
|
|
1347
|
+
this.#historyIndex = -1;
|
|
1348
|
+
this.#scrollOffset = 0;
|
|
1349
|
+
this.#undoStack.length = 0;
|
|
1350
|
+
|
|
1351
|
+
if (this.onChange) this.onChange("");
|
|
1352
|
+
if (this.onSubmit) this.onSubmit(result);
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1355
|
+
#handleBackspace(): void {
|
|
1356
|
+
this.#historyIndex = -1; // Exit history browsing mode
|
|
1357
|
+
this.#resetKillSequence();
|
|
1358
|
+
this.#recordUndoState();
|
|
1359
|
+
|
|
1360
|
+
if (this.#state.cursorCol > 0) {
|
|
1361
|
+
// Delete grapheme before cursor (handles emojis, combining characters, etc.)
|
|
1362
|
+
const line = this.#state.lines[this.#state.cursorLine] || "";
|
|
1363
|
+
const beforeCursor = line.slice(0, this.#state.cursorCol);
|
|
1364
|
+
|
|
1365
|
+
// Find the last grapheme in the text before cursor
|
|
1366
|
+
const graphemes = [...segmenter.segment(beforeCursor)];
|
|
1367
|
+
const lastGrapheme = graphemes[graphemes.length - 1];
|
|
1368
|
+
const graphemeLength = lastGrapheme ? lastGrapheme.segment.length : 1;
|
|
1369
|
+
|
|
1370
|
+
const before = line.slice(0, this.#state.cursorCol - graphemeLength);
|
|
1371
|
+
const after = line.slice(this.#state.cursorCol);
|
|
1372
|
+
|
|
1373
|
+
this.#state.lines[this.#state.cursorLine] = before + after;
|
|
1374
|
+
this.#setCursorCol(this.#state.cursorCol - graphemeLength);
|
|
1375
|
+
} else if (this.#state.cursorLine > 0) {
|
|
1376
|
+
// Merge with previous line
|
|
1377
|
+
const currentLine = this.#state.lines[this.#state.cursorLine] || "";
|
|
1378
|
+
const previousLine = this.#state.lines[this.#state.cursorLine - 1] || "";
|
|
1379
|
+
|
|
1380
|
+
this.#state.lines[this.#state.cursorLine - 1] = previousLine + currentLine;
|
|
1381
|
+
this.#state.lines.splice(this.#state.cursorLine, 1);
|
|
1382
|
+
|
|
1383
|
+
this.#state.cursorLine--;
|
|
1384
|
+
this.#setCursorCol(previousLine.length);
|
|
1385
|
+
}
|
|
1386
|
+
|
|
1387
|
+
if (this.onChange) {
|
|
1388
|
+
this.onChange(this.getText());
|
|
1389
|
+
}
|
|
1390
|
+
|
|
1391
|
+
// Update or re-trigger autocomplete after backspace
|
|
1392
|
+
if (this.#autocompleteState) {
|
|
1393
|
+
this.#debouncedUpdateAutocomplete();
|
|
1394
|
+
} else {
|
|
1395
|
+
// If autocomplete was cancelled (no matches), re-trigger if we're in a completable context
|
|
1396
|
+
const currentLine = this.#state.lines[this.#state.cursorLine] || "";
|
|
1397
|
+
const textBeforeCursor = currentLine.slice(0, this.#state.cursorCol);
|
|
1398
|
+
// Slash command context
|
|
1399
|
+
if (textBeforeCursor.trimStart().startsWith("/")) {
|
|
1400
|
+
this.#tryTriggerAutocomplete();
|
|
1401
|
+
}
|
|
1402
|
+
// @ file reference context
|
|
1403
|
+
else if (textBeforeCursor.match(/(?:^|[\s])@[^\s]*$/)) {
|
|
1404
|
+
this.#tryTriggerAutocomplete();
|
|
1405
|
+
}
|
|
1406
|
+
}
|
|
1407
|
+
}
|
|
1408
|
+
|
|
1409
|
+
/**
|
|
1410
|
+
* Set cursor column and clear preferredVisualCol.
|
|
1411
|
+
* Use this for all non-vertical cursor movements to reset sticky column behavior.
|
|
1412
|
+
*/
|
|
1413
|
+
#setCursorCol(col: number): void {
|
|
1414
|
+
this.#state.cursorCol = col;
|
|
1415
|
+
this.#preferredVisualCol = null;
|
|
1416
|
+
}
|
|
1417
|
+
|
|
1418
|
+
/**
|
|
1419
|
+
* Move cursor to a target visual line, applying sticky column logic.
|
|
1420
|
+
* Shared by moveCursor() and pageScroll().
|
|
1421
|
+
*/
|
|
1422
|
+
#moveToVisualLine(
|
|
1423
|
+
visualLines: Array<{ logicalLine: number; startCol: number; length: number }>,
|
|
1424
|
+
currentVisualLine: number,
|
|
1425
|
+
targetVisualLine: number,
|
|
1426
|
+
): void {
|
|
1427
|
+
const currentVL = visualLines[currentVisualLine];
|
|
1428
|
+
const targetVL = visualLines[targetVisualLine];
|
|
1429
|
+
|
|
1430
|
+
if (currentVL && targetVL) {
|
|
1431
|
+
const currentVisualCol = this.#state.cursorCol - currentVL.startCol;
|
|
1432
|
+
|
|
1433
|
+
// For non-last segments, clamp to length-1 to stay within the segment
|
|
1434
|
+
const isLastSourceSegment =
|
|
1435
|
+
currentVisualLine === visualLines.length - 1 ||
|
|
1436
|
+
visualLines[currentVisualLine + 1]?.logicalLine !== currentVL.logicalLine;
|
|
1437
|
+
const sourceMaxVisualCol = isLastSourceSegment ? currentVL.length : Math.max(0, currentVL.length - 1);
|
|
1438
|
+
|
|
1439
|
+
const isLastTargetSegment =
|
|
1440
|
+
targetVisualLine === visualLines.length - 1 ||
|
|
1441
|
+
visualLines[targetVisualLine + 1]?.logicalLine !== targetVL.logicalLine;
|
|
1442
|
+
const targetMaxVisualCol = isLastTargetSegment ? targetVL.length : Math.max(0, targetVL.length - 1);
|
|
1443
|
+
|
|
1444
|
+
const moveToVisualCol = this.#computeVerticalMoveColumn(
|
|
1445
|
+
currentVisualCol,
|
|
1446
|
+
sourceMaxVisualCol,
|
|
1447
|
+
targetMaxVisualCol,
|
|
1448
|
+
);
|
|
1449
|
+
|
|
1450
|
+
// Set cursor position
|
|
1451
|
+
this.#state.cursorLine = targetVL.logicalLine;
|
|
1452
|
+
const targetCol = targetVL.startCol + moveToVisualCol;
|
|
1453
|
+
const logicalLine = this.#state.lines[targetVL.logicalLine] || "";
|
|
1454
|
+
this.#state.cursorCol = Math.min(targetCol, logicalLine.length);
|
|
1455
|
+
}
|
|
1456
|
+
}
|
|
1457
|
+
|
|
1458
|
+
/**
|
|
1459
|
+
* Compute the target visual column for vertical cursor movement.
|
|
1460
|
+
* Implements the sticky column decision table.
|
|
1461
|
+
*/
|
|
1462
|
+
#computeVerticalMoveColumn(
|
|
1463
|
+
currentVisualCol: number,
|
|
1464
|
+
sourceMaxVisualCol: number,
|
|
1465
|
+
targetMaxVisualCol: number,
|
|
1466
|
+
): number {
|
|
1467
|
+
const hasPreferred = this.#preferredVisualCol !== null;
|
|
1468
|
+
const cursorInMiddle = currentVisualCol < sourceMaxVisualCol;
|
|
1469
|
+
const targetTooShort = targetMaxVisualCol < currentVisualCol;
|
|
1470
|
+
|
|
1471
|
+
if (!hasPreferred || cursorInMiddle) {
|
|
1472
|
+
if (targetTooShort) {
|
|
1473
|
+
this.#preferredVisualCol = currentVisualCol;
|
|
1474
|
+
return targetMaxVisualCol;
|
|
1475
|
+
}
|
|
1476
|
+
this.#preferredVisualCol = null;
|
|
1477
|
+
return currentVisualCol;
|
|
1478
|
+
}
|
|
1479
|
+
|
|
1480
|
+
const targetCantFitPreferred = targetMaxVisualCol < this.#preferredVisualCol!;
|
|
1481
|
+
if (targetTooShort || targetCantFitPreferred) {
|
|
1482
|
+
return targetMaxVisualCol;
|
|
1483
|
+
}
|
|
1484
|
+
|
|
1485
|
+
const result = this.#preferredVisualCol!;
|
|
1486
|
+
this.#preferredVisualCol = null;
|
|
1487
|
+
return result;
|
|
1488
|
+
}
|
|
1489
|
+
|
|
1490
|
+
#moveToLineStart(): void {
|
|
1491
|
+
this.#resetKillSequence();
|
|
1492
|
+
this.#setCursorCol(0);
|
|
1493
|
+
}
|
|
1494
|
+
|
|
1495
|
+
#moveToLineEnd(): void {
|
|
1496
|
+
this.#resetKillSequence();
|
|
1497
|
+
const currentLine = this.#state.lines[this.#state.cursorLine] || "";
|
|
1498
|
+
this.#setCursorCol(currentLine.length);
|
|
1499
|
+
}
|
|
1500
|
+
|
|
1501
|
+
#resetKillSequence(): void {
|
|
1502
|
+
this.#lastAction = null;
|
|
1503
|
+
}
|
|
1504
|
+
|
|
1505
|
+
#withUndoSuspended<T>(fn: () => T): T {
|
|
1506
|
+
const wasSuspended = this.#suspendUndo;
|
|
1507
|
+
this.#suspendUndo = true;
|
|
1508
|
+
try {
|
|
1509
|
+
return fn();
|
|
1510
|
+
} finally {
|
|
1511
|
+
this.#suspendUndo = wasSuspended;
|
|
1512
|
+
}
|
|
1513
|
+
}
|
|
1514
|
+
|
|
1515
|
+
#recordUndoState(): void {
|
|
1516
|
+
if (this.#suspendUndo) return;
|
|
1517
|
+
this.#undoStack.push(structuredClone(this.#state));
|
|
1518
|
+
}
|
|
1519
|
+
|
|
1520
|
+
#applyUndo(): void {
|
|
1521
|
+
const snapshot = this.#undoStack.pop();
|
|
1522
|
+
if (!snapshot) return;
|
|
1523
|
+
|
|
1524
|
+
this.#historyIndex = -1;
|
|
1525
|
+
this.#resetKillSequence();
|
|
1526
|
+
this.#preferredVisualCol = null;
|
|
1527
|
+
Object.assign(this.#state, snapshot);
|
|
1528
|
+
|
|
1529
|
+
if (this.onChange) {
|
|
1530
|
+
this.onChange(this.getText());
|
|
1531
|
+
}
|
|
1532
|
+
|
|
1533
|
+
if (this.#autocompleteState) {
|
|
1534
|
+
this.#debouncedUpdateAutocomplete();
|
|
1535
|
+
} else {
|
|
1536
|
+
const currentLine = this.#state.lines[this.#state.cursorLine] || "";
|
|
1537
|
+
const textBeforeCursor = currentLine.slice(0, this.#state.cursorCol);
|
|
1538
|
+
if (textBeforeCursor.trimStart().startsWith("/")) {
|
|
1539
|
+
this.#tryTriggerAutocomplete();
|
|
1540
|
+
} else if (textBeforeCursor.match(/(?:^|[\s])@[^\s]*$/)) {
|
|
1541
|
+
this.#tryTriggerAutocomplete();
|
|
1542
|
+
}
|
|
1543
|
+
}
|
|
1544
|
+
}
|
|
1545
|
+
|
|
1546
|
+
#recordKill(text: string, direction: "forward" | "backward", accumulate = this.#lastAction === "kill"): void {
|
|
1547
|
+
if (!text) return;
|
|
1548
|
+
this.#killRing.push(text, { prepend: direction === "backward", accumulate });
|
|
1549
|
+
this.#lastAction = "kill";
|
|
1550
|
+
}
|
|
1551
|
+
|
|
1552
|
+
#insertTextAtCursor(text: string): void {
|
|
1553
|
+
this.#historyIndex = -1;
|
|
1554
|
+
this.#resetKillSequence();
|
|
1555
|
+
this.#recordUndoState();
|
|
1556
|
+
|
|
1557
|
+
const normalized = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
1558
|
+
const lines = normalized.split("\n");
|
|
1559
|
+
|
|
1560
|
+
if (lines.length === 1) {
|
|
1561
|
+
const line = this.#state.lines[this.#state.cursorLine] || "";
|
|
1562
|
+
const before = line.slice(0, this.#state.cursorCol);
|
|
1563
|
+
const after = line.slice(this.#state.cursorCol);
|
|
1564
|
+
this.#state.lines[this.#state.cursorLine] = before + normalized + after;
|
|
1565
|
+
this.#setCursorCol(this.#state.cursorCol + normalized.length);
|
|
1566
|
+
} else {
|
|
1567
|
+
const currentLine = this.#state.lines[this.#state.cursorLine] || "";
|
|
1568
|
+
const beforeCursor = currentLine.slice(0, this.#state.cursorCol);
|
|
1569
|
+
const afterCursor = currentLine.slice(this.#state.cursorCol);
|
|
1570
|
+
|
|
1571
|
+
const newLines: string[] = [];
|
|
1572
|
+
for (let i = 0; i < this.#state.cursorLine; i++) {
|
|
1573
|
+
newLines.push(this.#state.lines[i] || "");
|
|
1574
|
+
}
|
|
1575
|
+
|
|
1576
|
+
newLines.push(beforeCursor + (lines[0] || ""));
|
|
1577
|
+
for (let i = 1; i < lines.length - 1; i++) {
|
|
1578
|
+
newLines.push(lines[i] || "");
|
|
1579
|
+
}
|
|
1580
|
+
newLines.push((lines[lines.length - 1] || "") + afterCursor);
|
|
1581
|
+
|
|
1582
|
+
for (let i = this.#state.cursorLine + 1; i < this.#state.lines.length; i++) {
|
|
1583
|
+
newLines.push(this.#state.lines[i] || "");
|
|
1584
|
+
}
|
|
1585
|
+
|
|
1586
|
+
this.#state.lines = newLines;
|
|
1587
|
+
this.#state.cursorLine += lines.length - 1;
|
|
1588
|
+
this.#setCursorCol((lines[lines.length - 1] || "").length);
|
|
1589
|
+
}
|
|
1590
|
+
|
|
1591
|
+
if (this.onChange) {
|
|
1592
|
+
this.onChange(this.getText());
|
|
1593
|
+
}
|
|
1594
|
+
}
|
|
1595
|
+
|
|
1596
|
+
#yankFromKillRing(): void {
|
|
1597
|
+
const text = this.#killRing.peek();
|
|
1598
|
+
if (!text) return;
|
|
1599
|
+
this.#insertTextAtCursor(text);
|
|
1600
|
+
this.#lastAction = "yank";
|
|
1601
|
+
}
|
|
1602
|
+
|
|
1603
|
+
#yankPop(): void {
|
|
1604
|
+
if (this.#lastAction !== "yank") return;
|
|
1605
|
+
if (this.#killRing.length <= 1) return;
|
|
1606
|
+
|
|
1607
|
+
this.#historyIndex = -1;
|
|
1608
|
+
this.#recordUndoState();
|
|
1609
|
+
|
|
1610
|
+
this.#withUndoSuspended(() => {
|
|
1611
|
+
if (!this.#deleteYankedText()) return;
|
|
1612
|
+
this.#killRing.rotate();
|
|
1613
|
+
const text = this.#killRing.peek();
|
|
1614
|
+
if (text) {
|
|
1615
|
+
this.#insertTextAtCursor(text);
|
|
1616
|
+
}
|
|
1617
|
+
});
|
|
1618
|
+
|
|
1619
|
+
this.#lastAction = "yank";
|
|
1620
|
+
}
|
|
1621
|
+
|
|
1622
|
+
/**
|
|
1623
|
+
* Delete the most recently yanked text from the buffer.
|
|
1624
|
+
*
|
|
1625
|
+
* This is a best-effort operation and assumes the cursor is still positioned
|
|
1626
|
+
* at the end of the yanked text.
|
|
1627
|
+
*/
|
|
1628
|
+
#deleteYankedText(): boolean {
|
|
1629
|
+
const yankedText = this.#killRing.peek();
|
|
1630
|
+
if (!yankedText) return false;
|
|
1631
|
+
|
|
1632
|
+
const yankLines = yankedText.split("\n");
|
|
1633
|
+
const endLine = this.#state.cursorLine;
|
|
1634
|
+
const endCol = this.#state.cursorCol;
|
|
1635
|
+
const startLine = endLine - (yankLines.length - 1);
|
|
1636
|
+
if (startLine < 0) return false;
|
|
1637
|
+
|
|
1638
|
+
if (yankLines.length === 1) {
|
|
1639
|
+
const line = this.#state.lines[endLine] ?? "";
|
|
1640
|
+
const startCol = endCol - yankedText.length;
|
|
1641
|
+
if (startCol < 0) return false;
|
|
1642
|
+
if (line.slice(startCol, endCol) !== yankedText) return false;
|
|
1643
|
+
|
|
1644
|
+
this.#state.lines[endLine] = line.slice(0, startCol) + line.slice(endCol);
|
|
1645
|
+
this.#state.cursorLine = endLine;
|
|
1646
|
+
this.#setCursorCol(startCol);
|
|
1647
|
+
return true;
|
|
1648
|
+
}
|
|
1649
|
+
|
|
1650
|
+
const firstInserted = yankLines[0] ?? "";
|
|
1651
|
+
const lastInserted = yankLines[yankLines.length - 1] ?? "";
|
|
1652
|
+
const firstLineText = this.#state.lines[startLine] ?? "";
|
|
1653
|
+
const lastLineText = this.#state.lines[endLine] ?? "";
|
|
1654
|
+
|
|
1655
|
+
if (!firstLineText.endsWith(firstInserted)) return false;
|
|
1656
|
+
if (endCol !== lastInserted.length) return false;
|
|
1657
|
+
if (lastLineText.slice(0, endCol) !== lastInserted) return false;
|
|
1658
|
+
|
|
1659
|
+
const startCol = firstLineText.length - firstInserted.length;
|
|
1660
|
+
if (startCol < 0) return false;
|
|
1661
|
+
|
|
1662
|
+
const suffix = lastLineText.slice(endCol);
|
|
1663
|
+
const newLine = firstLineText.slice(0, startCol) + suffix;
|
|
1664
|
+
|
|
1665
|
+
this.#state.lines.splice(startLine, yankLines.length, newLine);
|
|
1666
|
+
this.#state.cursorLine = startLine;
|
|
1667
|
+
this.#setCursorCol(startCol);
|
|
1668
|
+
return true;
|
|
1669
|
+
}
|
|
1670
|
+
|
|
1671
|
+
#deleteToStartOfLine(): void {
|
|
1672
|
+
this.#historyIndex = -1; // Exit history browsing mode
|
|
1673
|
+
this.#recordUndoState();
|
|
1674
|
+
|
|
1675
|
+
const currentLine = this.#state.lines[this.#state.cursorLine] || "";
|
|
1676
|
+
let deletedText = "";
|
|
1677
|
+
|
|
1678
|
+
if (this.#state.cursorCol > 0) {
|
|
1679
|
+
// Delete from start of line up to cursor
|
|
1680
|
+
deletedText = currentLine.slice(0, this.#state.cursorCol);
|
|
1681
|
+
this.#state.lines[this.#state.cursorLine] = currentLine.slice(this.#state.cursorCol);
|
|
1682
|
+
this.#setCursorCol(0);
|
|
1683
|
+
} else if (this.#state.cursorLine > 0) {
|
|
1684
|
+
// At start of line - merge with previous line
|
|
1685
|
+
deletedText = "\n";
|
|
1686
|
+
const previousLine = this.#state.lines[this.#state.cursorLine - 1] || "";
|
|
1687
|
+
this.#state.lines[this.#state.cursorLine - 1] = previousLine + currentLine;
|
|
1688
|
+
this.#state.lines.splice(this.#state.cursorLine, 1);
|
|
1689
|
+
this.#state.cursorLine--;
|
|
1690
|
+
this.#setCursorCol(previousLine.length);
|
|
1691
|
+
}
|
|
1692
|
+
|
|
1693
|
+
this.#recordKill(deletedText, "backward");
|
|
1694
|
+
|
|
1695
|
+
if (this.onChange) {
|
|
1696
|
+
this.onChange(this.getText());
|
|
1697
|
+
}
|
|
1698
|
+
}
|
|
1699
|
+
|
|
1700
|
+
#deleteToEndOfLine(): void {
|
|
1701
|
+
this.#historyIndex = -1; // Exit history browsing mode
|
|
1702
|
+
this.#recordUndoState();
|
|
1703
|
+
|
|
1704
|
+
const currentLine = this.#state.lines[this.#state.cursorLine] || "";
|
|
1705
|
+
let deletedText = "";
|
|
1706
|
+
|
|
1707
|
+
if (this.#state.cursorCol < currentLine.length) {
|
|
1708
|
+
// Delete from cursor to end of line
|
|
1709
|
+
deletedText = currentLine.slice(this.#state.cursorCol);
|
|
1710
|
+
this.#state.lines[this.#state.cursorLine] = currentLine.slice(0, this.#state.cursorCol);
|
|
1711
|
+
} else if (this.#state.cursorLine < this.#state.lines.length - 1) {
|
|
1712
|
+
// At end of line - merge with next line
|
|
1713
|
+
const nextLine = this.#state.lines[this.#state.cursorLine + 1] || "";
|
|
1714
|
+
deletedText = "\n";
|
|
1715
|
+
this.#state.lines[this.#state.cursorLine] = currentLine + nextLine;
|
|
1716
|
+
this.#state.lines.splice(this.#state.cursorLine + 1, 1);
|
|
1717
|
+
}
|
|
1718
|
+
|
|
1719
|
+
this.#recordKill(deletedText, "forward");
|
|
1720
|
+
|
|
1721
|
+
if (this.onChange) {
|
|
1722
|
+
this.onChange(this.getText());
|
|
1723
|
+
}
|
|
1724
|
+
}
|
|
1725
|
+
|
|
1726
|
+
#deleteWordBackwards(): void {
|
|
1727
|
+
this.#historyIndex = -1; // Exit history browsing mode
|
|
1728
|
+
this.#recordUndoState();
|
|
1729
|
+
|
|
1730
|
+
const currentLine = this.#state.lines[this.#state.cursorLine] || "";
|
|
1731
|
+
|
|
1732
|
+
// If at start of line, behave like backspace at column 0 (merge with previous line)
|
|
1733
|
+
if (this.#state.cursorCol === 0) {
|
|
1734
|
+
if (this.#state.cursorLine > 0) {
|
|
1735
|
+
this.#recordKill("\n", "backward");
|
|
1736
|
+
const previousLine = this.#state.lines[this.#state.cursorLine - 1] || "";
|
|
1737
|
+
this.#state.lines[this.#state.cursorLine - 1] = previousLine + currentLine;
|
|
1738
|
+
this.#state.lines.splice(this.#state.cursorLine, 1);
|
|
1739
|
+
this.#state.cursorLine--;
|
|
1740
|
+
this.#setCursorCol(previousLine.length);
|
|
1741
|
+
}
|
|
1742
|
+
} else {
|
|
1743
|
+
const oldCursorCol = this.#state.cursorCol;
|
|
1744
|
+
this.#moveWordBackwards();
|
|
1745
|
+
const deleteFrom = this.#state.cursorCol;
|
|
1746
|
+
this.#setCursorCol(oldCursorCol);
|
|
1747
|
+
|
|
1748
|
+
const deletedText = currentLine.slice(deleteFrom, oldCursorCol);
|
|
1749
|
+
this.#state.lines[this.#state.cursorLine] =
|
|
1750
|
+
currentLine.slice(0, deleteFrom) + currentLine.slice(this.#state.cursorCol);
|
|
1751
|
+
this.#setCursorCol(deleteFrom);
|
|
1752
|
+
this.#recordKill(deletedText, "backward");
|
|
1753
|
+
}
|
|
1754
|
+
|
|
1755
|
+
if (this.onChange) {
|
|
1756
|
+
this.onChange(this.getText());
|
|
1757
|
+
}
|
|
1758
|
+
}
|
|
1759
|
+
|
|
1760
|
+
#deleteWordForwards(): void {
|
|
1761
|
+
this.#historyIndex = -1; // Exit history browsing mode
|
|
1762
|
+
this.#recordUndoState();
|
|
1763
|
+
|
|
1764
|
+
const currentLine = this.#state.lines[this.#state.cursorLine] || "";
|
|
1765
|
+
|
|
1766
|
+
if (this.#state.cursorCol >= currentLine.length) {
|
|
1767
|
+
if (this.#state.cursorLine < this.#state.lines.length - 1) {
|
|
1768
|
+
this.#recordKill("\n", "forward");
|
|
1769
|
+
const nextLine = this.#state.lines[this.#state.cursorLine + 1] || "";
|
|
1770
|
+
this.#state.lines[this.#state.cursorLine] = currentLine + nextLine;
|
|
1771
|
+
this.#state.lines.splice(this.#state.cursorLine + 1, 1);
|
|
1772
|
+
}
|
|
1773
|
+
} else {
|
|
1774
|
+
const oldCursorCol = this.#state.cursorCol;
|
|
1775
|
+
this.#moveWordForwards();
|
|
1776
|
+
const deleteTo = this.#state.cursorCol;
|
|
1777
|
+
this.#setCursorCol(oldCursorCol);
|
|
1778
|
+
|
|
1779
|
+
const deletedText = currentLine.slice(oldCursorCol, deleteTo);
|
|
1780
|
+
this.#state.lines[this.#state.cursorLine] = currentLine.slice(0, oldCursorCol) + currentLine.slice(deleteTo);
|
|
1781
|
+
this.#recordKill(deletedText, "forward");
|
|
1782
|
+
}
|
|
1783
|
+
|
|
1784
|
+
if (this.onChange) {
|
|
1785
|
+
this.onChange(this.getText());
|
|
1786
|
+
}
|
|
1787
|
+
}
|
|
1788
|
+
|
|
1789
|
+
#handleForwardDelete(): void {
|
|
1790
|
+
this.#historyIndex = -1; // Exit history browsing mode
|
|
1791
|
+
this.#resetKillSequence();
|
|
1792
|
+
this.#recordUndoState();
|
|
1793
|
+
|
|
1794
|
+
const currentLine = this.#state.lines[this.#state.cursorLine] || "";
|
|
1795
|
+
|
|
1796
|
+
if (this.#state.cursorCol < currentLine.length) {
|
|
1797
|
+
// Delete grapheme at cursor position (handles emojis, combining characters, etc.)
|
|
1798
|
+
const afterCursor = currentLine.slice(this.#state.cursorCol);
|
|
1799
|
+
|
|
1800
|
+
// Find the first grapheme at cursor
|
|
1801
|
+
const graphemes = [...segmenter.segment(afterCursor)];
|
|
1802
|
+
const firstGrapheme = graphemes[0];
|
|
1803
|
+
const graphemeLength = firstGrapheme ? firstGrapheme.segment.length : 1;
|
|
1804
|
+
|
|
1805
|
+
const before = currentLine.slice(0, this.#state.cursorCol);
|
|
1806
|
+
const after = currentLine.slice(this.#state.cursorCol + graphemeLength);
|
|
1807
|
+
this.#state.lines[this.#state.cursorLine] = before + after;
|
|
1808
|
+
} else if (this.#state.cursorLine < this.#state.lines.length - 1) {
|
|
1809
|
+
// At end of line - merge with next line
|
|
1810
|
+
const nextLine = this.#state.lines[this.#state.cursorLine + 1] || "";
|
|
1811
|
+
this.#state.lines[this.#state.cursorLine] = currentLine + nextLine;
|
|
1812
|
+
this.#state.lines.splice(this.#state.cursorLine + 1, 1);
|
|
1813
|
+
}
|
|
1814
|
+
|
|
1815
|
+
if (this.onChange) {
|
|
1816
|
+
this.onChange(this.getText());
|
|
1817
|
+
}
|
|
1818
|
+
|
|
1819
|
+
// Update or re-trigger autocomplete after forward delete
|
|
1820
|
+
if (this.#autocompleteState) {
|
|
1821
|
+
this.#debouncedUpdateAutocomplete();
|
|
1822
|
+
} else {
|
|
1823
|
+
const currentLine = this.#state.lines[this.#state.cursorLine] || "";
|
|
1824
|
+
const textBeforeCursor = currentLine.slice(0, this.#state.cursorCol);
|
|
1825
|
+
// Slash command context
|
|
1826
|
+
if (textBeforeCursor.trimStart().startsWith("/")) {
|
|
1827
|
+
this.#tryTriggerAutocomplete();
|
|
1828
|
+
}
|
|
1829
|
+
// @ file reference context
|
|
1830
|
+
else if (textBeforeCursor.match(/(?:^|[\s])@[^\s]*$/)) {
|
|
1831
|
+
this.#tryTriggerAutocomplete();
|
|
1832
|
+
}
|
|
1833
|
+
}
|
|
1834
|
+
}
|
|
1835
|
+
|
|
1836
|
+
/**
|
|
1837
|
+
* Build a mapping from visual lines to logical positions.
|
|
1838
|
+
* Returns an array where each element represents a visual line with:
|
|
1839
|
+
* - logicalLine: index into this.#state.lines
|
|
1840
|
+
* - startCol: starting column in the logical line
|
|
1841
|
+
* - length: length of this visual line segment
|
|
1842
|
+
*/
|
|
1843
|
+
#buildVisualLineMap(width: number): Array<{ logicalLine: number; startCol: number; length: number }> {
|
|
1844
|
+
const visualLines: Array<{ logicalLine: number; startCol: number; length: number }> = [];
|
|
1845
|
+
|
|
1846
|
+
for (let i = 0; i < this.#state.lines.length; i++) {
|
|
1847
|
+
const line = this.#state.lines[i] || "";
|
|
1848
|
+
const lineVisWidth = visibleWidth(line);
|
|
1849
|
+
if (line.length === 0) {
|
|
1850
|
+
// Empty line still takes one visual line
|
|
1851
|
+
visualLines.push({ logicalLine: i, startCol: 0, length: 0 });
|
|
1852
|
+
} else if (lineVisWidth <= width) {
|
|
1853
|
+
visualLines.push({ logicalLine: i, startCol: 0, length: line.length });
|
|
1854
|
+
} else {
|
|
1855
|
+
// Line needs wrapping - use word-aware wrapping
|
|
1856
|
+
const chunks = wordWrapLine(line, width);
|
|
1857
|
+
for (const chunk of chunks) {
|
|
1858
|
+
visualLines.push({
|
|
1859
|
+
logicalLine: i,
|
|
1860
|
+
startCol: chunk.startIndex,
|
|
1861
|
+
length: chunk.endIndex - chunk.startIndex,
|
|
1862
|
+
});
|
|
1863
|
+
}
|
|
1864
|
+
}
|
|
1865
|
+
}
|
|
1866
|
+
|
|
1867
|
+
return visualLines;
|
|
1868
|
+
}
|
|
1869
|
+
|
|
1870
|
+
/**
|
|
1871
|
+
* Find the visual line index for the current cursor position.
|
|
1872
|
+
*/
|
|
1873
|
+
#findCurrentVisualLine(visualLines: Array<{ logicalLine: number; startCol: number; length: number }>): number {
|
|
1874
|
+
for (let i = 0; i < visualLines.length; i++) {
|
|
1875
|
+
const vl = visualLines[i];
|
|
1876
|
+
if (!vl) continue;
|
|
1877
|
+
if (vl.logicalLine === this.#state.cursorLine) {
|
|
1878
|
+
const colInSegment = this.#state.cursorCol - vl.startCol;
|
|
1879
|
+
// Cursor is in this segment if it's within range
|
|
1880
|
+
// For the last segment of a logical line, cursor can be at length (end position)
|
|
1881
|
+
const isLastSegmentOfLine =
|
|
1882
|
+
i === visualLines.length - 1 || visualLines[i + 1]?.logicalLine !== vl.logicalLine;
|
|
1883
|
+
if (colInSegment >= 0 && (colInSegment < vl.length || (isLastSegmentOfLine && colInSegment <= vl.length))) {
|
|
1884
|
+
return i;
|
|
1885
|
+
}
|
|
1886
|
+
}
|
|
1887
|
+
}
|
|
1888
|
+
// Fallback: return last visual line
|
|
1889
|
+
return visualLines.length - 1;
|
|
1890
|
+
}
|
|
1891
|
+
|
|
1892
|
+
#moveCursor(deltaLine: number, deltaCol: number): void {
|
|
1893
|
+
this.#resetKillSequence();
|
|
1894
|
+
const visualLines = this.#buildVisualLineMap(this.#lastLayoutWidth);
|
|
1895
|
+
const currentVisualLine = this.#findCurrentVisualLine(visualLines);
|
|
1896
|
+
|
|
1897
|
+
if (deltaLine !== 0) {
|
|
1898
|
+
const targetVisualLine = currentVisualLine + deltaLine;
|
|
1899
|
+
|
|
1900
|
+
if (targetVisualLine >= 0 && targetVisualLine < visualLines.length) {
|
|
1901
|
+
this.#moveToVisualLine(visualLines, currentVisualLine, targetVisualLine);
|
|
1902
|
+
}
|
|
1903
|
+
}
|
|
1904
|
+
|
|
1905
|
+
if (deltaCol !== 0) {
|
|
1906
|
+
const currentLine = this.#state.lines[this.#state.cursorLine] || "";
|
|
1907
|
+
|
|
1908
|
+
if (deltaCol > 0) {
|
|
1909
|
+
// Moving right - move by one grapheme (handles emojis, combining characters, etc.)
|
|
1910
|
+
if (this.#state.cursorCol < currentLine.length) {
|
|
1911
|
+
const afterCursor = currentLine.slice(this.#state.cursorCol);
|
|
1912
|
+
const graphemes = [...segmenter.segment(afterCursor)];
|
|
1913
|
+
const firstGrapheme = graphemes[0];
|
|
1914
|
+
this.#setCursorCol(this.#state.cursorCol + (firstGrapheme ? firstGrapheme.segment.length : 1));
|
|
1915
|
+
} else if (this.#state.cursorLine < this.#state.lines.length - 1) {
|
|
1916
|
+
// Wrap to start of next logical line
|
|
1917
|
+
this.#state.cursorLine++;
|
|
1918
|
+
this.#setCursorCol(0);
|
|
1919
|
+
} else {
|
|
1920
|
+
// At end of last line - can't move, but set preferredVisualCol for up/down navigation
|
|
1921
|
+
const currentVL = visualLines[currentVisualLine];
|
|
1922
|
+
if (currentVL) {
|
|
1923
|
+
this.#preferredVisualCol = this.#state.cursorCol - currentVL.startCol;
|
|
1924
|
+
}
|
|
1925
|
+
}
|
|
1926
|
+
} else {
|
|
1927
|
+
// Moving left - move by one grapheme (handles emojis, combining characters, etc.)
|
|
1928
|
+
if (this.#state.cursorCol > 0) {
|
|
1929
|
+
const beforeCursor = currentLine.slice(0, this.#state.cursorCol);
|
|
1930
|
+
const graphemes = [...segmenter.segment(beforeCursor)];
|
|
1931
|
+
const lastGrapheme = graphemes[graphemes.length - 1];
|
|
1932
|
+
this.#setCursorCol(this.#state.cursorCol - (lastGrapheme ? lastGrapheme.segment.length : 1));
|
|
1933
|
+
} else if (this.#state.cursorLine > 0) {
|
|
1934
|
+
// Wrap to end of previous logical line
|
|
1935
|
+
this.#state.cursorLine--;
|
|
1936
|
+
const prevLine = this.#state.lines[this.#state.cursorLine] || "";
|
|
1937
|
+
this.#setCursorCol(prevLine.length);
|
|
1938
|
+
}
|
|
1939
|
+
}
|
|
1940
|
+
}
|
|
1941
|
+
}
|
|
1942
|
+
|
|
1943
|
+
#pageScroll(direction: -1 | 1): void {
|
|
1944
|
+
this.#resetKillSequence();
|
|
1945
|
+
const visualLines = this.#buildVisualLineMap(this.#lastLayoutWidth);
|
|
1946
|
+
const currentVisualLine = this.#findCurrentVisualLine(visualLines);
|
|
1947
|
+
const step = this.#getPageScrollStep(visualLines.length);
|
|
1948
|
+
const targetVisualLine = Math.max(0, Math.min(visualLines.length - 1, currentVisualLine + direction * step));
|
|
1949
|
+
if (targetVisualLine === currentVisualLine) return;
|
|
1950
|
+
this.#moveToVisualLine(visualLines, currentVisualLine, targetVisualLine);
|
|
1951
|
+
}
|
|
1952
|
+
|
|
1953
|
+
#moveWordBackwards(): void {
|
|
1954
|
+
const currentLine = this.#state.lines[this.#state.cursorLine] || "";
|
|
1955
|
+
|
|
1956
|
+
// If at start of line, move to end of previous line
|
|
1957
|
+
if (this.#state.cursorCol === 0) {
|
|
1958
|
+
if (this.#state.cursorLine > 0) {
|
|
1959
|
+
this.#state.cursorLine--;
|
|
1960
|
+
const prevLine = this.#state.lines[this.#state.cursorLine] || "";
|
|
1961
|
+
this.#setCursorCol(prevLine.length);
|
|
1962
|
+
}
|
|
1963
|
+
return;
|
|
1964
|
+
}
|
|
1965
|
+
|
|
1966
|
+
const textBeforeCursor = currentLine.slice(0, this.#state.cursorCol);
|
|
1967
|
+
const graphemes = [...segmenter.segment(textBeforeCursor)];
|
|
1968
|
+
let newCol = this.#state.cursorCol;
|
|
1969
|
+
|
|
1970
|
+
// Skip trailing whitespace
|
|
1971
|
+
while (graphemes.length > 0 && isWhitespaceChar(graphemes[graphemes.length - 1]?.segment || "")) {
|
|
1972
|
+
newCol -= graphemes.pop()?.segment.length || 0;
|
|
1973
|
+
}
|
|
1974
|
+
|
|
1975
|
+
if (graphemes.length > 0) {
|
|
1976
|
+
const lastGrapheme = graphemes[graphemes.length - 1]?.segment || "";
|
|
1977
|
+
if (isPunctuationChar(lastGrapheme)) {
|
|
1978
|
+
// Skip punctuation run
|
|
1979
|
+
while (graphemes.length > 0 && isPunctuationChar(graphemes[graphemes.length - 1]?.segment || "")) {
|
|
1980
|
+
newCol -= graphemes.pop()?.segment.length || 0;
|
|
1981
|
+
}
|
|
1982
|
+
} else {
|
|
1983
|
+
// Skip word run
|
|
1984
|
+
while (
|
|
1985
|
+
graphemes.length > 0 &&
|
|
1986
|
+
!isWhitespaceChar(graphemes[graphemes.length - 1]?.segment || "") &&
|
|
1987
|
+
!isPunctuationChar(graphemes[graphemes.length - 1]?.segment || "")
|
|
1988
|
+
) {
|
|
1989
|
+
newCol -= graphemes.pop()?.segment.length || 0;
|
|
1990
|
+
}
|
|
1991
|
+
}
|
|
1992
|
+
}
|
|
1993
|
+
|
|
1994
|
+
this.#setCursorCol(newCol);
|
|
1995
|
+
}
|
|
1996
|
+
|
|
1997
|
+
/**
|
|
1998
|
+
* Jump to the first occurrence of a character in the specified direction.
|
|
1999
|
+
* Multi-line search. Case-sensitive. Skips the current cursor position.
|
|
2000
|
+
*/
|
|
2001
|
+
#jumpToChar(char: string, direction: "forward" | "backward"): void {
|
|
2002
|
+
this.#resetKillSequence();
|
|
2003
|
+
const isForward = direction === "forward";
|
|
2004
|
+
const lines = this.#state.lines;
|
|
2005
|
+
|
|
2006
|
+
const end = isForward ? lines.length : -1;
|
|
2007
|
+
const step = isForward ? 1 : -1;
|
|
2008
|
+
|
|
2009
|
+
for (let lineIdx = this.#state.cursorLine; lineIdx !== end; lineIdx += step) {
|
|
2010
|
+
const line = lines[lineIdx] || "";
|
|
2011
|
+
const isCurrentLine = lineIdx === this.#state.cursorLine;
|
|
2012
|
+
|
|
2013
|
+
// Current line: start after/before cursor; other lines: search full line
|
|
2014
|
+
const searchFrom = isCurrentLine
|
|
2015
|
+
? isForward
|
|
2016
|
+
? this.#state.cursorCol + 1
|
|
2017
|
+
: this.#state.cursorCol - 1
|
|
2018
|
+
: undefined;
|
|
2019
|
+
|
|
2020
|
+
const idx = isForward ? line.indexOf(char, searchFrom) : line.lastIndexOf(char, searchFrom);
|
|
2021
|
+
|
|
2022
|
+
if (idx !== -1) {
|
|
2023
|
+
this.#state.cursorLine = lineIdx;
|
|
2024
|
+
this.#setCursorCol(idx);
|
|
2025
|
+
return;
|
|
2026
|
+
}
|
|
2027
|
+
}
|
|
2028
|
+
// No match found - cursor stays in place
|
|
2029
|
+
}
|
|
2030
|
+
|
|
2031
|
+
#moveWordForwards(): void {
|
|
2032
|
+
const currentLine = this.#state.lines[this.#state.cursorLine] || "";
|
|
2033
|
+
|
|
2034
|
+
// If at end of line, move to start of next line
|
|
2035
|
+
if (this.#state.cursorCol >= currentLine.length) {
|
|
2036
|
+
if (this.#state.cursorLine < this.#state.lines.length - 1) {
|
|
2037
|
+
this.#state.cursorLine++;
|
|
2038
|
+
this.#setCursorCol(0);
|
|
2039
|
+
}
|
|
2040
|
+
return;
|
|
2041
|
+
}
|
|
2042
|
+
|
|
2043
|
+
const textAfterCursor = currentLine.slice(this.#state.cursorCol);
|
|
2044
|
+
const segments = segmenter.segment(textAfterCursor);
|
|
2045
|
+
const iterator = segments[Symbol.iterator]();
|
|
2046
|
+
let next = iterator.next();
|
|
2047
|
+
let newCol = this.#state.cursorCol;
|
|
2048
|
+
|
|
2049
|
+
// Skip leading whitespace
|
|
2050
|
+
while (!next.done && isWhitespaceChar(next.value.segment)) {
|
|
2051
|
+
newCol += next.value.segment.length;
|
|
2052
|
+
next = iterator.next();
|
|
2053
|
+
}
|
|
2054
|
+
|
|
2055
|
+
if (!next.done) {
|
|
2056
|
+
const firstGrapheme = next.value.segment;
|
|
2057
|
+
if (isPunctuationChar(firstGrapheme)) {
|
|
2058
|
+
// Skip punctuation run
|
|
2059
|
+
while (!next.done && isPunctuationChar(next.value.segment)) {
|
|
2060
|
+
newCol += next.value.segment.length;
|
|
2061
|
+
next = iterator.next();
|
|
2062
|
+
}
|
|
2063
|
+
} else {
|
|
2064
|
+
// Skip word run
|
|
2065
|
+
while (!next.done && !isWhitespaceChar(next.value.segment) && !isPunctuationChar(next.value.segment)) {
|
|
2066
|
+
newCol += next.value.segment.length;
|
|
2067
|
+
next = iterator.next();
|
|
2068
|
+
}
|
|
2069
|
+
}
|
|
2070
|
+
}
|
|
2071
|
+
|
|
2072
|
+
this.#setCursorCol(newCol);
|
|
2073
|
+
}
|
|
2074
|
+
|
|
2075
|
+
// Helper method to check if cursor is at start of message (for slash command detection)
|
|
2076
|
+
#isAtStartOfMessage(): boolean {
|
|
2077
|
+
const currentLine = this.#state.lines[this.#state.cursorLine] || "";
|
|
2078
|
+
const beforeCursor = currentLine.slice(0, this.#state.cursorCol);
|
|
2079
|
+
|
|
2080
|
+
// At start if line is empty, only contains whitespace, or is just "/"
|
|
2081
|
+
return beforeCursor.trim() === "" || beforeCursor.trim() === "/";
|
|
2082
|
+
}
|
|
2083
|
+
|
|
2084
|
+
// Autocomplete methods
|
|
2085
|
+
async #tryTriggerAutocomplete(explicitTab: boolean = false): Promise<void> {
|
|
2086
|
+
if (!this.#autocompleteProvider) return;
|
|
2087
|
+
// Check if we should trigger file completion on Tab
|
|
2088
|
+
if (explicitTab) {
|
|
2089
|
+
const provider = this.#autocompleteProvider as CombinedAutocompleteProvider;
|
|
2090
|
+
const shouldTrigger =
|
|
2091
|
+
!provider.shouldTriggerFileCompletion ||
|
|
2092
|
+
provider.shouldTriggerFileCompletion(this.#state.lines, this.#state.cursorLine, this.#state.cursorCol);
|
|
2093
|
+
if (!shouldTrigger) {
|
|
2094
|
+
return;
|
|
2095
|
+
}
|
|
2096
|
+
}
|
|
2097
|
+
|
|
2098
|
+
const requestId = ++this.#autocompleteRequestId;
|
|
2099
|
+
|
|
2100
|
+
const suggestions = await this.#autocompleteProvider.getSuggestions(
|
|
2101
|
+
this.#state.lines,
|
|
2102
|
+
this.#state.cursorLine,
|
|
2103
|
+
this.#state.cursorCol,
|
|
2104
|
+
);
|
|
2105
|
+
if (requestId !== this.#autocompleteRequestId) return;
|
|
2106
|
+
|
|
2107
|
+
if (suggestions && suggestions.items.length > 0) {
|
|
2108
|
+
this.#autocompletePrefix = suggestions.prefix;
|
|
2109
|
+
this.#autocompleteList = new SelectList(
|
|
2110
|
+
suggestions.items,
|
|
2111
|
+
this.#autocompleteMaxVisible,
|
|
2112
|
+
this.#theme.selectList,
|
|
2113
|
+
);
|
|
2114
|
+
this.#autocompleteState = "regular";
|
|
2115
|
+
this.onAutocompleteUpdate?.();
|
|
2116
|
+
} else {
|
|
2117
|
+
this.#cancelAutocomplete();
|
|
2118
|
+
this.onAutocompleteUpdate?.();
|
|
2119
|
+
}
|
|
2120
|
+
}
|
|
2121
|
+
|
|
2122
|
+
#handleTabCompletion(): void {
|
|
2123
|
+
if (!this.#autocompleteProvider) return;
|
|
2124
|
+
|
|
2125
|
+
const currentLine = this.#state.lines[this.#state.cursorLine] || "";
|
|
2126
|
+
const beforeCursor = currentLine.slice(0, this.#state.cursorCol);
|
|
2127
|
+
|
|
2128
|
+
// Check if we're in a slash command context
|
|
2129
|
+
if (beforeCursor.trimStart().startsWith("/") && !beforeCursor.trimStart().includes(" ")) {
|
|
2130
|
+
this.#handleSlashCommandCompletion();
|
|
2131
|
+
} else {
|
|
2132
|
+
this.#forceFileAutocomplete(true);
|
|
2133
|
+
}
|
|
2134
|
+
}
|
|
2135
|
+
|
|
2136
|
+
#handleSlashCommandCompletion(): void {
|
|
2137
|
+
this.#tryTriggerAutocomplete(true);
|
|
2138
|
+
}
|
|
2139
|
+
|
|
2140
|
+
/*
|
|
2141
|
+
https://github.com/EsotericSoftware/spine-runtimes/actions/runs/19536643416/job/559322883
|
|
2142
|
+
17 this job fails with https://github.com/EsotericSoftware/spine-runtimes/actions/runs/19
|
|
2143
|
+
536643416/job/55932288317 havea look at .gi
|
|
2144
|
+
*/
|
|
2145
|
+
async #forceFileAutocomplete(explicitTab: boolean = false): Promise<void> {
|
|
2146
|
+
if (!this.#autocompleteProvider) return;
|
|
2147
|
+
|
|
2148
|
+
// Check if provider supports force file suggestions via runtime check
|
|
2149
|
+
const provider = this.#autocompleteProvider as {
|
|
2150
|
+
getForceFileSuggestions?: CombinedAutocompleteProvider["getForceFileSuggestions"];
|
|
2151
|
+
};
|
|
2152
|
+
if (typeof provider.getForceFileSuggestions !== "function") {
|
|
2153
|
+
await this.#tryTriggerAutocomplete(true);
|
|
2154
|
+
return;
|
|
2155
|
+
}
|
|
2156
|
+
|
|
2157
|
+
const requestId = ++this.#autocompleteRequestId;
|
|
2158
|
+
const suggestions = await provider.getForceFileSuggestions(
|
|
2159
|
+
this.#state.lines,
|
|
2160
|
+
this.#state.cursorLine,
|
|
2161
|
+
this.#state.cursorCol,
|
|
2162
|
+
);
|
|
2163
|
+
if (requestId !== this.#autocompleteRequestId) return;
|
|
2164
|
+
|
|
2165
|
+
if (suggestions && suggestions.items.length > 0) {
|
|
2166
|
+
// If there's exactly one suggestion and this was an explicit Tab press, apply it immediately
|
|
2167
|
+
if (explicitTab && suggestions.items.length === 1) {
|
|
2168
|
+
const item = suggestions.items[0]!;
|
|
2169
|
+
const result = this.#autocompleteProvider.applyCompletion(
|
|
2170
|
+
this.#state.lines,
|
|
2171
|
+
this.#state.cursorLine,
|
|
2172
|
+
this.#state.cursorCol,
|
|
2173
|
+
item,
|
|
2174
|
+
suggestions.prefix,
|
|
2175
|
+
);
|
|
2176
|
+
|
|
2177
|
+
this.#state.lines = result.lines;
|
|
2178
|
+
this.#state.cursorLine = result.cursorLine;
|
|
2179
|
+
this.#setCursorCol(result.cursorCol);
|
|
2180
|
+
|
|
2181
|
+
if (this.onChange) {
|
|
2182
|
+
this.onChange(this.getText());
|
|
2183
|
+
}
|
|
2184
|
+
return;
|
|
2185
|
+
}
|
|
2186
|
+
|
|
2187
|
+
this.#autocompletePrefix = suggestions.prefix;
|
|
2188
|
+
this.#autocompleteList = new SelectList(
|
|
2189
|
+
suggestions.items,
|
|
2190
|
+
this.#autocompleteMaxVisible,
|
|
2191
|
+
this.#theme.selectList,
|
|
2192
|
+
);
|
|
2193
|
+
this.#autocompleteState = "force";
|
|
2194
|
+
this.onAutocompleteUpdate?.();
|
|
2195
|
+
} else {
|
|
2196
|
+
this.#cancelAutocomplete();
|
|
2197
|
+
this.onAutocompleteUpdate?.();
|
|
2198
|
+
}
|
|
2199
|
+
}
|
|
2200
|
+
|
|
2201
|
+
#cancelAutocomplete(notifyCancel: boolean = false): void {
|
|
2202
|
+
const wasAutocompleting = this.#autocompleteState !== null;
|
|
2203
|
+
this.#clearAutocompleteTimeout();
|
|
2204
|
+
this.#autocompleteRequestId += 1;
|
|
2205
|
+
this.#autocompleteState = null;
|
|
2206
|
+
this.#autocompleteList = undefined;
|
|
2207
|
+
this.#autocompletePrefix = "";
|
|
2208
|
+
if (notifyCancel && wasAutocompleting) {
|
|
2209
|
+
this.onAutocompleteCancel?.();
|
|
2210
|
+
}
|
|
2211
|
+
}
|
|
2212
|
+
|
|
2213
|
+
isShowingAutocomplete(): boolean {
|
|
2214
|
+
return this.#autocompleteState !== null;
|
|
2215
|
+
}
|
|
2216
|
+
|
|
2217
|
+
async #updateAutocomplete(): Promise<void> {
|
|
2218
|
+
if (!this.#autocompleteState || !this.#autocompleteProvider) return;
|
|
2219
|
+
|
|
2220
|
+
// In force mode, use forceFileAutocomplete to get suggestions
|
|
2221
|
+
if (this.#autocompleteState === "force") {
|
|
2222
|
+
this.#forceFileAutocomplete();
|
|
2223
|
+
return;
|
|
2224
|
+
}
|
|
2225
|
+
|
|
2226
|
+
const requestId = ++this.#autocompleteRequestId;
|
|
2227
|
+
|
|
2228
|
+
const suggestions = await this.#autocompleteProvider.getSuggestions(
|
|
2229
|
+
this.#state.lines,
|
|
2230
|
+
this.#state.cursorLine,
|
|
2231
|
+
this.#state.cursorCol,
|
|
2232
|
+
);
|
|
2233
|
+
if (requestId !== this.#autocompleteRequestId) return;
|
|
2234
|
+
|
|
2235
|
+
if (suggestions && suggestions.items.length > 0) {
|
|
2236
|
+
this.#autocompletePrefix = suggestions.prefix;
|
|
2237
|
+
// Always create new SelectList to ensure update
|
|
2238
|
+
this.#autocompleteList = new SelectList(
|
|
2239
|
+
suggestions.items,
|
|
2240
|
+
this.#autocompleteMaxVisible,
|
|
2241
|
+
this.#theme.selectList,
|
|
2242
|
+
);
|
|
2243
|
+
this.onAutocompleteUpdate?.();
|
|
2244
|
+
} else {
|
|
2245
|
+
this.#cancelAutocomplete();
|
|
2246
|
+
this.onAutocompleteUpdate?.();
|
|
2247
|
+
}
|
|
2248
|
+
}
|
|
2249
|
+
|
|
2250
|
+
#debouncedUpdateAutocomplete(): void {
|
|
2251
|
+
if (this.#autocompleteTimeout) {
|
|
2252
|
+
clearTimeout(this.#autocompleteTimeout);
|
|
2253
|
+
}
|
|
2254
|
+
this.#autocompleteTimeout = setTimeout(() => {
|
|
2255
|
+
this.#updateAutocomplete();
|
|
2256
|
+
this.#autocompleteTimeout = undefined;
|
|
2257
|
+
}, 100);
|
|
2258
|
+
}
|
|
2259
|
+
|
|
2260
|
+
#clearAutocompleteTimeout(): void {
|
|
2261
|
+
if (this.#autocompleteTimeout) {
|
|
2262
|
+
clearTimeout(this.#autocompleteTimeout);
|
|
2263
|
+
this.#autocompleteTimeout = undefined;
|
|
2264
|
+
}
|
|
2265
|
+
}
|
|
2266
|
+
|
|
2267
|
+
/**
|
|
2268
|
+
* Get inline hint text to show as dim ghost text after the cursor.
|
|
2269
|
+
* Checks selected autocomplete item's hint first, then falls back to provider.
|
|
2270
|
+
*/
|
|
2271
|
+
#getInlineHint(): string | null {
|
|
2272
|
+
// Check selected autocomplete item for a hint
|
|
2273
|
+
if (this.#autocompleteState && this.#autocompleteList) {
|
|
2274
|
+
const selected = this.#autocompleteList.getSelectedItem();
|
|
2275
|
+
return selected?.hint ?? null;
|
|
2276
|
+
}
|
|
2277
|
+
|
|
2278
|
+
// Fall back to provider's getInlineHint
|
|
2279
|
+
if (this.#autocompleteProvider?.getInlineHint) {
|
|
2280
|
+
return this.#autocompleteProvider.getInlineHint(
|
|
2281
|
+
this.#state.lines,
|
|
2282
|
+
this.#state.cursorLine,
|
|
2283
|
+
this.#state.cursorCol,
|
|
2284
|
+
);
|
|
2285
|
+
}
|
|
2286
|
+
|
|
2287
|
+
return null;
|
|
2288
|
+
}
|
|
2289
|
+
}
|