@prometheus-ai/tui 0.5.3 → 0.5.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/types/autocomplete.d.ts +3 -1
- package/dist/types/components/box.d.ts +1 -1
- package/dist/types/components/editor.d.ts +35 -2
- package/dist/types/components/image.d.ts +22 -3
- package/dist/types/components/input.d.ts +6 -1
- package/dist/types/components/loader.d.ts +9 -2
- package/dist/types/components/markdown.d.ts +3 -1
- package/dist/types/components/scroll-view.d.ts +23 -1
- package/dist/types/components/select-list.d.ts +19 -1
- package/dist/types/components/settings-list.d.ts +87 -7
- package/dist/types/components/spacer.d.ts +1 -1
- package/dist/types/components/tab-bar.d.ts +37 -4
- package/dist/types/components/text.d.ts +2 -2
- package/dist/types/components/truncated-text.d.ts +1 -1
- package/dist/types/fuzzy.d.ts +10 -1
- package/dist/types/index.d.ts +1 -0
- package/dist/types/keybindings.d.ts +5 -3
- package/dist/types/keys.d.ts +1 -1
- package/dist/types/kill-ring.d.ts +0 -7
- package/dist/types/kitty-graphics.d.ts +16 -31
- package/dist/types/loop-watchdog.d.ts +39 -0
- package/dist/types/mouse.d.ts +41 -0
- package/dist/types/stdin-buffer.d.ts +17 -0
- package/dist/types/terminal-capabilities.d.ts +74 -18
- package/dist/types/terminal.d.ts +34 -36
- package/dist/types/tui.d.ts +191 -79
- package/dist/types/utils.d.ts +5 -2
- package/package.json +4 -4
- package/src/autocomplete.ts +79 -65
- package/src/components/box.ts +43 -63
- package/src/components/editor.ts +471 -136
- package/src/components/image.ts +85 -9
- package/src/components/input.ts +12 -3
- package/src/components/loader.ts +35 -21
- package/src/components/markdown.ts +174 -53
- package/src/components/scroll-view.ts +63 -2
- package/src/components/select-list.ts +233 -38
- package/src/components/settings-list.ts +626 -64
- package/src/components/spacer.ts +9 -5
- package/src/components/tab-bar.ts +153 -28
- package/src/components/text.ts +6 -2
- package/src/components/truncated-text.ts +10 -2
- package/src/fuzzy.ts +214 -59
- package/src/index.ts +3 -1
- package/src/keybindings.ts +72 -14
- package/src/keys.ts +1 -1
- package/src/kill-ring.ts +5 -0
- package/src/kitty-graphics.ts +2 -101
- package/src/loop-watchdog.ts +106 -0
- package/src/mouse.ts +55 -0
- package/src/stdin-buffer.ts +291 -81
- package/src/terminal-capabilities.ts +206 -168
- package/src/terminal.ts +367 -110
- package/src/tui.ts +2102 -1729
- package/src/utils.ts +92 -60
package/src/components/editor.ts
CHANGED
|
@@ -27,6 +27,7 @@ const SLASH_COMMAND_SELECT_LIST_LAYOUT: SelectListLayoutOptions = {
|
|
|
27
27
|
minPrimaryColumnWidth: 12,
|
|
28
28
|
maxPrimaryColumnWidth: 32,
|
|
29
29
|
overflowSearch: false,
|
|
30
|
+
wrapDescription: true,
|
|
30
31
|
};
|
|
31
32
|
|
|
32
33
|
function sanitizeLoadedText(text: string): string {
|
|
@@ -138,8 +139,12 @@ function wordWrapLine(line: string, maxWidth: number): TextChunk[] {
|
|
|
138
139
|
for (const token of tokens) {
|
|
139
140
|
const tokenWidth = visibleWidth(token.text);
|
|
140
141
|
|
|
141
|
-
// Skip leading whitespace at line start
|
|
142
|
+
// Skip leading whitespace at line start. Keep the skipped run mapped onto the
|
|
143
|
+
// preceding chunk (when one exists) so every cursor position resolves to a
|
|
144
|
+
// layout line instead of falling through to the buffer's last visual line.
|
|
142
145
|
if (atLineStart && token.isWhitespace) {
|
|
146
|
+
const prev = chunks[chunks.length - 1];
|
|
147
|
+
if (prev) prev.endIndex = token.endIndex;
|
|
143
148
|
chunkStartIndex = token.endIndex;
|
|
144
149
|
continue;
|
|
145
150
|
}
|
|
@@ -240,10 +245,19 @@ function wordWrapLine(line: string, maxWidth: number): TextChunk[] {
|
|
|
240
245
|
startIndex: chunkStartIndex,
|
|
241
246
|
endIndex: chunkStartIndex + currentChunk.length,
|
|
242
247
|
});
|
|
248
|
+
} else {
|
|
249
|
+
// All-whitespace chunk collapsed away: keep its span mapped on the
|
|
250
|
+
// previous chunk so cursor positions inside it stay addressable.
|
|
251
|
+
const prev = chunks[chunks.length - 1];
|
|
252
|
+
if (prev) prev.endIndex = chunkStartIndex + currentChunk.length;
|
|
243
253
|
}
|
|
244
254
|
// Start new line - skip leading whitespace
|
|
245
255
|
atLineStart = true;
|
|
246
256
|
if (token.isWhitespace) {
|
|
257
|
+
// Extend the preceding chunk over the whitespace run skipped at the wrap
|
|
258
|
+
// point; otherwise cursor positions inside it map to no layout line.
|
|
259
|
+
const prev = chunks[chunks.length - 1];
|
|
260
|
+
if (prev) prev.endIndex = token.endIndex;
|
|
247
261
|
currentChunk = "";
|
|
248
262
|
currentWidth = 0;
|
|
249
263
|
chunkStartIndex = token.endIndex;
|
|
@@ -272,8 +286,47 @@ function wordWrapLine(line: string, maxWidth: number): TextChunk[] {
|
|
|
272
286
|
return chunks.length > 0 ? chunks : [{ text: "", startIndex: 0, endIndex: 0 }];
|
|
273
287
|
}
|
|
274
288
|
|
|
289
|
+
/** Visual cell column of code-unit `offset` within `text`, counted by grapheme walk. */
|
|
290
|
+
function visualColAtOffset(text: string, offset: number): number {
|
|
291
|
+
if (offset <= 0) return 0;
|
|
292
|
+
let col = 0;
|
|
293
|
+
for (const seg of segmenter.segment(text)) {
|
|
294
|
+
if (seg.index >= offset) break;
|
|
295
|
+
col += visibleWidth(seg.segment);
|
|
296
|
+
}
|
|
297
|
+
return col;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** Code-unit offset of visual cell `col` within `text`, snapped to a grapheme
|
|
301
|
+
* boundary so the result never splits a surrogate pair or cluster. */
|
|
302
|
+
function offsetAtVisualCol(text: string, col: number): number {
|
|
303
|
+
if (col <= 0) return 0;
|
|
304
|
+
let current = 0;
|
|
305
|
+
for (const seg of segmenter.segment(text)) {
|
|
306
|
+
const width = visibleWidth(seg.segment);
|
|
307
|
+
if (current + width > col) return seg.index;
|
|
308
|
+
current += width;
|
|
309
|
+
}
|
|
310
|
+
return text.length;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** Highest visual column the cursor may occupy on a wrap segment: the full width
|
|
314
|
+
* on a logical line's last segment, otherwise just before the final grapheme
|
|
315
|
+
* (the segment end is the next segment's start). */
|
|
316
|
+
function maxSegmentVisualCol(text: string, isLastSegment: boolean): number {
|
|
317
|
+
let total = 0;
|
|
318
|
+
let lastWidth = 0;
|
|
319
|
+
for (const seg of segmenter.segment(text)) {
|
|
320
|
+
lastWidth = visibleWidth(seg.segment);
|
|
321
|
+
total += lastWidth;
|
|
322
|
+
}
|
|
323
|
+
return isLastSegment ? total : Math.max(0, total - lastWidth);
|
|
324
|
+
}
|
|
325
|
+
|
|
275
326
|
const DEFAULT_PAGE_SCROLL_LINES = 10;
|
|
276
327
|
|
|
328
|
+
const MAX_UNDO_STACK = 100;
|
|
329
|
+
|
|
277
330
|
interface EditorState {
|
|
278
331
|
lines: string[];
|
|
279
332
|
cursorLine: number;
|
|
@@ -338,13 +391,18 @@ export class Editor implements Component, Focusable {
|
|
|
338
391
|
|
|
339
392
|
// Store last layout width for cursor navigation
|
|
340
393
|
#lastLayoutWidth: number = 80;
|
|
394
|
+
// Word-wrap result cache shared by #layoutText, #buildVisualLineMap, and key
|
|
395
|
+
// handlers within a frame. Line text is a sound key (strings are immutable);
|
|
396
|
+
// cleared on width change and size-bounded so stale lines don't accumulate.
|
|
397
|
+
#wrapCache = new Map<string, TextChunk[]>();
|
|
398
|
+
#wrapCacheWidth = -1;
|
|
341
399
|
#paddingXOverride: number | undefined;
|
|
342
400
|
#maxHeight?: number;
|
|
343
401
|
#scrollOffset: number = 0;
|
|
344
402
|
|
|
345
403
|
// Emacs-style kill ring
|
|
346
404
|
#killRing = new KillRing();
|
|
347
|
-
#lastAction: "kill" | "yank" | null = null;
|
|
405
|
+
#lastAction: "kill" | "yank" | "type-word" | null = null;
|
|
348
406
|
|
|
349
407
|
// Character jump mode
|
|
350
408
|
#jumpMode: "forward" | "backward" | null = null;
|
|
@@ -368,6 +426,15 @@ export class Editor implements Component, Focusable {
|
|
|
368
426
|
#pastes: Map<number, string> = new Map();
|
|
369
427
|
#pasteCounter: number = 0;
|
|
370
428
|
|
|
429
|
+
/** Optional pattern matching atomic placeholder tokens (e.g. `[Image #1, 800x600]` or
|
|
430
|
+
* `[Paste #2, +30 lines]`) that the editor treats as indivisible: a backspace or forward-delete
|
|
431
|
+
* landing on any character of a token removes the whole token instead of corrupting it into
|
|
432
|
+
* stray text. MUST be a global regex; the editor recompiles a private copy so its `lastIndex`
|
|
433
|
+
* is never shared with the caller. */
|
|
434
|
+
atomicTokenPattern: RegExp | undefined;
|
|
435
|
+
#atomicTokenSource: string | undefined;
|
|
436
|
+
#atomicTokenRe: RegExp | undefined;
|
|
437
|
+
|
|
371
438
|
// Bracketed paste mode buffering
|
|
372
439
|
#pasteHandler = new BracketedPasteHandler();
|
|
373
440
|
|
|
@@ -383,9 +450,16 @@ export class Editor implements Component, Focusable {
|
|
|
383
450
|
// Debounce timer for autocomplete updates
|
|
384
451
|
#autocompleteTimeout?: NodeJS.Timeout;
|
|
385
452
|
|
|
386
|
-
onSubmit?: (text: string) => void
|
|
453
|
+
onSubmit?: (text: string) => void | Promise<void>;
|
|
387
454
|
onAltEnter?: (text: string) => void;
|
|
388
455
|
onChange?: (text: string) => void;
|
|
456
|
+
/** Called for a "marker-sized" paste — the point where the editor would otherwise collapse it
|
|
457
|
+
* into a `[Paste #N]` token (> 10 lines or > 1000 characters). Return `true` to intercept:
|
|
458
|
+
* the editor inserts nothing and records no undo state, leaving insertion to the host (e.g. a
|
|
459
|
+
* "wrap in a code block / XML / attach as file" menu for very large pastes), which re-inserts
|
|
460
|
+
* via {@link insertPaste} or {@link insertText}. Return `false` (or leave unset) for the
|
|
461
|
+
* default collapse-to-marker behavior. `lineCount` is the sanitized paste's line count. */
|
|
462
|
+
onLargePaste?: (text: string, lineCount: number) => boolean;
|
|
389
463
|
onAutocompleteCancel?: () => void;
|
|
390
464
|
disableSubmit: boolean = false;
|
|
391
465
|
|
|
@@ -593,10 +667,20 @@ export class Editor implements Component, Focusable {
|
|
|
593
667
|
}
|
|
594
668
|
|
|
595
669
|
/** Apply the optional input decorator to a plain (ANSI-free) text segment.
|
|
596
|
-
* Decoration only adds zero-width SGR codes, so visible width is unchanged.
|
|
670
|
+
* Decoration only adds zero-width SGR codes, so visible width is unchanged.
|
|
671
|
+
* Splits around CURSOR_MARKER so each user-text segment is decorated in
|
|
672
|
+
* isolation: the marker begins with ESC, and a keyword regex that pins
|
|
673
|
+
* the right boundary with `(?!\S)` would otherwise reject an otherwise-
|
|
674
|
+
* valid match at the cursor seam (e.g. `ultrathink` immediately followed
|
|
675
|
+
* by the marker stops glowing until a trailing character is typed). */
|
|
597
676
|
#decorate(text: string): string {
|
|
598
677
|
const decorate = this.decorateText;
|
|
599
|
-
|
|
678
|
+
if (decorate === undefined || text.length === 0) return text;
|
|
679
|
+
const idx = text.indexOf(CURSOR_MARKER);
|
|
680
|
+
if (idx === -1) return decorate(text);
|
|
681
|
+
const before = text.slice(0, idx);
|
|
682
|
+
const after = text.slice(idx + CURSOR_MARKER.length);
|
|
683
|
+
return (before.length > 0 ? decorate(before) : "") + CURSOR_MARKER + (after.length > 0 ? decorate(after) : "");
|
|
600
684
|
}
|
|
601
685
|
|
|
602
686
|
#getStyledInputCursor(): { text: string; width: number } {
|
|
@@ -691,7 +775,7 @@ export class Editor implements Component, Focusable {
|
|
|
691
775
|
this.#scrollOffset = Math.min(this.#scrollOffset, maxOffset);
|
|
692
776
|
}
|
|
693
777
|
|
|
694
|
-
render(width: number): string[] {
|
|
778
|
+
render(width: number): readonly string[] {
|
|
695
779
|
const paddingX = this.#getEditorPaddingX();
|
|
696
780
|
const borderVisible = this.#borderVisible;
|
|
697
781
|
const promptGutter = this.#getPromptGutter(width, paddingX);
|
|
@@ -808,9 +892,10 @@ export class Editor implements Component, Focusable {
|
|
|
808
892
|
const before = displayText.slice(0, layoutLine.cursorPos);
|
|
809
893
|
const after = displayText.slice(layoutLine.cursorPos);
|
|
810
894
|
if (after.length === 0 && inlineHint) {
|
|
811
|
-
const
|
|
895
|
+
const availWidth = Math.max(0, lineContentWidth - displayWidth);
|
|
896
|
+
const hintText = hintStyle(truncateToWidth(inlineHint, availWidth));
|
|
812
897
|
displayText = before + marker + hintText;
|
|
813
|
-
displayWidth += visibleWidth(inlineHint);
|
|
898
|
+
displayWidth += Math.min(visibleWidth(inlineHint), availWidth);
|
|
814
899
|
} else if (after.length === 0 && !borderVisible && displayWidth >= lineContentWidth) {
|
|
815
900
|
displayText = this.#renderTerminalCursorMarker(before, marker, lineContentWidth);
|
|
816
901
|
} else {
|
|
@@ -879,9 +964,9 @@ export class Editor implements Component, Focusable {
|
|
|
879
964
|
}
|
|
880
965
|
}
|
|
881
966
|
|
|
882
|
-
// No cursor on this line, or a branch that left the user text intact: decorate
|
|
883
|
-
// whole line.
|
|
884
|
-
//
|
|
967
|
+
// No cursor on this line, or a branch that left the user text intact: decorate
|
|
968
|
+
// the whole line. `#decorate` splits around CURSOR_MARKER so a keyword glued to
|
|
969
|
+
// the cursor still satisfies its right-boundary lookahead.
|
|
885
970
|
if (!decorated) {
|
|
886
971
|
displayText = this.#decorate(displayText);
|
|
887
972
|
}
|
|
@@ -1109,12 +1194,18 @@ export class Editor implements Component, Focusable {
|
|
|
1109
1194
|
else if (matchesKey(data, "ctrl+w")) {
|
|
1110
1195
|
this.#deleteWordBackwards();
|
|
1111
1196
|
}
|
|
1112
|
-
// Option/Alt+Backspace - Delete word backwards
|
|
1113
|
-
|
|
1197
|
+
// Option/Alt+Backspace - Delete word backwards.
|
|
1198
|
+
// Ghostty on macOS reports Option+Backspace as super+alt (kitty mod 11) — see #2064.
|
|
1199
|
+
else if (matchesKey(data, "alt+backspace") || matchesKey(data, "super+alt+backspace")) {
|
|
1114
1200
|
this.#deleteWordBackwards();
|
|
1115
1201
|
}
|
|
1116
|
-
// Option/Alt+D - Delete word forwards
|
|
1117
|
-
else if (
|
|
1202
|
+
// Option/Alt+D and Option+Delete - Delete word forwards. Same Ghostty quirk applies.
|
|
1203
|
+
else if (
|
|
1204
|
+
matchesKey(data, "alt+d") ||
|
|
1205
|
+
matchesKey(data, "alt+delete") ||
|
|
1206
|
+
matchesKey(data, "super+alt+d") ||
|
|
1207
|
+
matchesKey(data, "super+alt+delete")
|
|
1208
|
+
) {
|
|
1118
1209
|
this.#deleteWordForwards();
|
|
1119
1210
|
}
|
|
1120
1211
|
// Ctrl+Y - Yank from kill ring
|
|
@@ -1288,6 +1379,22 @@ export class Editor implements Component, Focusable {
|
|
|
1288
1379
|
}
|
|
1289
1380
|
}
|
|
1290
1381
|
|
|
1382
|
+
#wrapLine(line: string, width: number): TextChunk[] {
|
|
1383
|
+
if (width !== this.#wrapCacheWidth) {
|
|
1384
|
+
this.#wrapCache.clear();
|
|
1385
|
+
this.#wrapCacheWidth = width;
|
|
1386
|
+
}
|
|
1387
|
+
let chunks = this.#wrapCache.get(line);
|
|
1388
|
+
if (chunks === undefined) {
|
|
1389
|
+
if (this.#wrapCache.size >= 256) {
|
|
1390
|
+
this.#wrapCache.clear();
|
|
1391
|
+
}
|
|
1392
|
+
chunks = wordWrapLine(line, width);
|
|
1393
|
+
this.#wrapCache.set(line, chunks);
|
|
1394
|
+
}
|
|
1395
|
+
return chunks;
|
|
1396
|
+
}
|
|
1397
|
+
|
|
1291
1398
|
#layoutText(contentWidth: number): LayoutLine[] {
|
|
1292
1399
|
const layoutLines: LayoutLine[] = [];
|
|
1293
1400
|
|
|
@@ -1323,7 +1430,7 @@ export class Editor implements Component, Focusable {
|
|
|
1323
1430
|
}
|
|
1324
1431
|
} else {
|
|
1325
1432
|
// Line needs wrapping - use word-aware wrapping
|
|
1326
|
-
const chunks =
|
|
1433
|
+
const chunks = this.#wrapLine(line, contentWidth);
|
|
1327
1434
|
|
|
1328
1435
|
for (let chunkIndex = 0; chunkIndex < chunks.length; chunkIndex++) {
|
|
1329
1436
|
const chunk = chunks[chunkIndex];
|
|
@@ -1339,21 +1446,19 @@ export class Editor implements Component, Focusable {
|
|
|
1339
1446
|
let adjustedCursorPos = 0;
|
|
1340
1447
|
|
|
1341
1448
|
if (isCurrentLine) {
|
|
1449
|
+
// The first chunk owns any leading whitespace the wrapper skipped,
|
|
1450
|
+
// so a cursor inside it still maps to a layout line.
|
|
1451
|
+
const chunkStart = chunkIndex === 0 ? 0 : chunk.startIndex;
|
|
1342
1452
|
if (isLastChunk) {
|
|
1343
1453
|
// Last chunk: cursor belongs here if >= startIndex
|
|
1344
|
-
hasCursorInChunk = cursorPos >=
|
|
1345
|
-
adjustedCursorPos = cursorPos - chunk.startIndex;
|
|
1454
|
+
hasCursorInChunk = cursorPos >= chunkStart;
|
|
1346
1455
|
} else {
|
|
1347
1456
|
// Non-last chunk: cursor belongs here if in range [startIndex, endIndex)
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
if (adjustedCursorPos > chunk.text.length) {
|
|
1354
|
-
adjustedCursorPos = chunk.text.length;
|
|
1355
|
-
}
|
|
1356
|
-
}
|
|
1457
|
+
hasCursorInChunk = cursorPos >= chunkStart && cursorPos < chunk.endIndex;
|
|
1458
|
+
}
|
|
1459
|
+
if (hasCursorInChunk) {
|
|
1460
|
+
// Clamp into the displayed text (cursor may sit in trimmed/skipped whitespace)
|
|
1461
|
+
adjustedCursorPos = Math.max(0, Math.min(cursorPos - chunk.startIndex, chunk.text.length));
|
|
1357
1462
|
}
|
|
1358
1463
|
}
|
|
1359
1464
|
|
|
@@ -1383,7 +1488,7 @@ export class Editor implements Component, Focusable {
|
|
|
1383
1488
|
#expandPasteMarkers(text: string): string {
|
|
1384
1489
|
let result = text;
|
|
1385
1490
|
for (const [pasteId, pasteContent] of this.#pastes) {
|
|
1386
|
-
const markerRegex = new RegExp(`\\[
|
|
1491
|
+
const markerRegex = new RegExp(`\\[Paste #${pasteId}(?:, (?:\\+\\d+ lines|\\d+ chars))?\\]`, "g");
|
|
1387
1492
|
result = result.replace(markerRegex, () => pasteContent);
|
|
1388
1493
|
}
|
|
1389
1494
|
return result;
|
|
@@ -1495,11 +1600,112 @@ export class Editor implements Component, Focusable {
|
|
|
1495
1600
|
this.#insertTextAtCursor(text);
|
|
1496
1601
|
}
|
|
1497
1602
|
|
|
1498
|
-
|
|
1499
|
-
|
|
1603
|
+
/** Delete up to `count` characters immediately before the cursor on the current line.
|
|
1604
|
+
* Used to "track back" the auto-repeat spaces that the space-hold push-to-talk gesture
|
|
1605
|
+
* optimistically inserts before it recognizes the hold. Capped at the cursor column so it
|
|
1606
|
+
* never crosses a line boundary or under-runs the line. */
|
|
1607
|
+
deleteBeforeCursor(count: number): void {
|
|
1608
|
+
const removable = Math.min(count, this.#state.cursorCol);
|
|
1609
|
+
if (removable <= 0) return;
|
|
1500
1610
|
this.#exitHistoryForEditing();
|
|
1611
|
+
this.#recordUndoState();
|
|
1612
|
+
const line = this.#state.lines[this.#state.cursorLine] ?? "";
|
|
1613
|
+
this.#state.lines[this.#state.cursorLine] =
|
|
1614
|
+
line.slice(0, this.#state.cursorCol - removable) + line.slice(this.#state.cursorCol);
|
|
1615
|
+
this.#setCursorCol(this.#state.cursorCol - removable);
|
|
1616
|
+
this.#lastAction = null;
|
|
1617
|
+
if (this.onChange) {
|
|
1618
|
+
this.onChange(this.getText());
|
|
1619
|
+
}
|
|
1620
|
+
}
|
|
1621
|
+
|
|
1622
|
+
/** Code units of the current volatile speech-to-text preview (see {@link setVolatileText}). */
|
|
1623
|
+
#volatileTextLen = 0;
|
|
1624
|
+
|
|
1625
|
+
/** Show or replace a volatile speech-to-text preview at the cursor. The text is
|
|
1626
|
+
* inserted with undo suspended so a long live dictation never floods the undo
|
|
1627
|
+
* stack; finalize it with {@link commitVolatileText} or drop it with
|
|
1628
|
+
* {@link clearVolatileText}. Newlines are allowed. */
|
|
1629
|
+
setVolatileText(text: string): void {
|
|
1630
|
+
this.#exitHistoryForEditing();
|
|
1631
|
+
this.#withUndoSuspended(() => {
|
|
1632
|
+
this.#deleteCharsBeforeCursor(this.#volatileTextLen);
|
|
1633
|
+
if (text) this.#insertTextAtCursor(text);
|
|
1634
|
+
});
|
|
1635
|
+
this.#volatileTextLen = text.length;
|
|
1636
|
+
if (!text && this.onChange) this.onChange(this.getText());
|
|
1637
|
+
}
|
|
1638
|
+
|
|
1639
|
+
/** Remove the current volatile preview without committing it. */
|
|
1640
|
+
clearVolatileText(): void {
|
|
1641
|
+
if (this.#volatileTextLen === 0) return;
|
|
1642
|
+
this.#withUndoSuspended(() => this.#deleteCharsBeforeCursor(this.#volatileTextLen));
|
|
1643
|
+
this.#volatileTextLen = 0;
|
|
1644
|
+
if (this.onChange) this.onChange(this.getText());
|
|
1645
|
+
}
|
|
1646
|
+
|
|
1647
|
+
/** Drop any volatile preview, then insert `text` as a single undoable edit. */
|
|
1648
|
+
commitVolatileText(text: string): void {
|
|
1649
|
+
this.#exitHistoryForEditing();
|
|
1650
|
+
this.#withUndoSuspended(() => this.#deleteCharsBeforeCursor(this.#volatileTextLen));
|
|
1651
|
+
this.#volatileTextLen = 0;
|
|
1652
|
+
if (text) this.#insertTextAtCursor(text);
|
|
1653
|
+
else if (this.onChange) this.onChange(this.getText());
|
|
1654
|
+
}
|
|
1655
|
+
|
|
1656
|
+
/** Delete `count` UTF-16 code units immediately before the cursor, crossing line
|
|
1657
|
+
* boundaries (each consumed newline counts as one). Undo is the caller's concern. */
|
|
1658
|
+
#deleteCharsBeforeCursor(count: number): void {
|
|
1659
|
+
let remaining = count;
|
|
1660
|
+
while (remaining > 0) {
|
|
1661
|
+
if (this.#state.cursorCol > 0) {
|
|
1662
|
+
const removable = Math.min(remaining, this.#state.cursorCol);
|
|
1663
|
+
const line = this.#state.lines[this.#state.cursorLine] ?? "";
|
|
1664
|
+
this.#state.lines[this.#state.cursorLine] =
|
|
1665
|
+
line.slice(0, this.#state.cursorCol - removable) + line.slice(this.#state.cursorCol);
|
|
1666
|
+
this.#setCursorCol(this.#state.cursorCol - removable);
|
|
1667
|
+
remaining -= removable;
|
|
1668
|
+
} else if (this.#state.cursorLine > 0) {
|
|
1669
|
+
const prev = this.#state.lines[this.#state.cursorLine - 1] ?? "";
|
|
1670
|
+
const cur = this.#state.lines[this.#state.cursorLine] ?? "";
|
|
1671
|
+
this.#state.lines[this.#state.cursorLine - 1] = prev + cur;
|
|
1672
|
+
this.#state.lines.splice(this.#state.cursorLine, 1);
|
|
1673
|
+
this.#state.cursorLine -= 1;
|
|
1674
|
+
this.#setCursorCol(prev.length);
|
|
1675
|
+
remaining -= 1;
|
|
1676
|
+
} else {
|
|
1677
|
+
break;
|
|
1678
|
+
}
|
|
1679
|
+
}
|
|
1680
|
+
}
|
|
1681
|
+
|
|
1682
|
+
/** Apply terminal paste semantics to text from non-bracketed paste transports. */
|
|
1683
|
+
pasteText(text: string): void {
|
|
1684
|
+
this.#handlePaste(text);
|
|
1685
|
+
}
|
|
1686
|
+
|
|
1687
|
+
/** Insert `content` as a collapsed `[Paste #N]` marker (stored for expansion on submit via
|
|
1688
|
+
* {@link getExpandedText}). Hosts that intercept large pastes through {@link onLargePaste} use
|
|
1689
|
+
* this to re-insert a (possibly transformed) paste without re-triggering the interception hook. */
|
|
1690
|
+
insertPaste(content: string): void {
|
|
1691
|
+
this.#historyIndex = -1;
|
|
1501
1692
|
this.#resetKillSequence();
|
|
1502
1693
|
this.#recordUndoState();
|
|
1694
|
+
this.#withUndoSuspended(() => {
|
|
1695
|
+
this.#storePasteMarker(content, content.split("\n").length);
|
|
1696
|
+
});
|
|
1697
|
+
}
|
|
1698
|
+
|
|
1699
|
+
// All the editor methods from before...
|
|
1700
|
+
#insertCharacter(char: string): void {
|
|
1701
|
+
this.#exitHistoryForEditing();
|
|
1702
|
+
// Undo coalescing: consecutive word typing collapses into one undo unit
|
|
1703
|
+
// (mirrors Input); any other action resets the run via #lastAction.
|
|
1704
|
+
const isWordChunk = [...segmenter.segment(char)].every(seg => getWordNavKind(seg.segment) !== "whitespace");
|
|
1705
|
+
if (!isWordChunk || this.#lastAction !== "type-word") {
|
|
1706
|
+
this.#recordUndoState();
|
|
1707
|
+
}
|
|
1708
|
+
this.#lastAction = isWordChunk ? "type-word" : null;
|
|
1503
1709
|
|
|
1504
1710
|
const line = this.#state.lines[this.#state.cursorLine] || "";
|
|
1505
1711
|
|
|
@@ -1587,76 +1793,47 @@ export class Editor implements Component, Focusable {
|
|
|
1587
1793
|
}
|
|
1588
1794
|
|
|
1589
1795
|
#handlePaste(pastedText: string): void {
|
|
1590
|
-
|
|
1591
|
-
this.#resetKillSequence();
|
|
1592
|
-
this.#recordUndoState();
|
|
1593
|
-
|
|
1594
|
-
this.#withUndoSuspended(() => {
|
|
1595
|
-
// Some terminals (e.g. tmux popups with extended-keys-format=csi-u) re-encode
|
|
1596
|
-
// control bytes inside bracketed paste as CSI-u Ctrl+<letter> sequences
|
|
1597
|
-
// (ESC [ <codepoint> ; 5 u). Decode those back to their literal byte so the
|
|
1598
|
-
// per-char filter below preserves newlines instead of stripping ESC and
|
|
1599
|
-
// leaking the printable tail (e.g. "[106;5u") into the editor.
|
|
1600
|
-
const decodedText = pastedText.replace(/\x1b\[(\d+);5u/g, (match, code) => {
|
|
1601
|
-
const cp = Number(code);
|
|
1602
|
-
if (cp >= 97 && cp <= 122) return String.fromCharCode(cp - 96);
|
|
1603
|
-
if (cp >= 65 && cp <= 90) return String.fromCharCode(cp - 64);
|
|
1604
|
-
return match;
|
|
1605
|
-
});
|
|
1796
|
+
let filteredText = this.#sanitizePastedText(pastedText);
|
|
1606
1797
|
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
const cleanText = decodedText.replace(/\r\n?/g, "\n").normalize("NFC");
|
|
1615
|
-
|
|
1616
|
-
// Convert tabs to spaces (4 spaces per tab)
|
|
1617
|
-
const tabExpandedText = cleanText.replace(/\t/g, " ");
|
|
1618
|
-
|
|
1619
|
-
// Filter out non-printable characters except newlines
|
|
1620
|
-
let filteredText = tabExpandedText
|
|
1621
|
-
.split("")
|
|
1622
|
-
.filter(char => char === "\n" || char.charCodeAt(0) >= 32)
|
|
1623
|
-
.join("");
|
|
1624
|
-
|
|
1625
|
-
// If pasting a file path (starts with /, ~, or .) and the character before
|
|
1626
|
-
// the cursor is a word character, prepend a space for better readability
|
|
1627
|
-
if (/^[/~.]/.test(filteredText)) {
|
|
1628
|
-
const currentLine = this.#state.lines[this.#state.cursorLine] || "";
|
|
1629
|
-
const charBeforeCursor = this.#state.cursorCol > 0 ? currentLine[this.#state.cursorCol - 1] : "";
|
|
1630
|
-
if (charBeforeCursor && /\w/.test(charBeforeCursor)) {
|
|
1631
|
-
filteredText = ` ${filteredText}`;
|
|
1632
|
-
}
|
|
1798
|
+
// If pasting a file path (starts with /, ~, or .) and the character before
|
|
1799
|
+
// the cursor is a word character, prepend a space for better readability.
|
|
1800
|
+
if (/^[/~.]/.test(filteredText)) {
|
|
1801
|
+
const currentLine = this.#state.lines[this.#state.cursorLine] || "";
|
|
1802
|
+
const charBeforeCursor = this.#state.cursorCol > 0 ? currentLine[this.#state.cursorCol - 1] : "";
|
|
1803
|
+
if (charBeforeCursor && /\w/.test(charBeforeCursor)) {
|
|
1804
|
+
filteredText = ` ${filteredText}`;
|
|
1633
1805
|
}
|
|
1806
|
+
}
|
|
1634
1807
|
|
|
1635
|
-
|
|
1636
|
-
|
|
1808
|
+
const pastedLines = filteredText.split("\n");
|
|
1809
|
+
const totalChars = filteredText.length;
|
|
1810
|
+
// "Marker-sized": large enough to collapse into a `[Paste #N]` token (> 10 lines or
|
|
1811
|
+
// > 1000 characters) instead of flooding the buffer.
|
|
1812
|
+
const isMarkerSized = pastedLines.length > 10 || totalChars > 1000;
|
|
1637
1813
|
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
this.#pastes.set(pasteId, filteredText);
|
|
1814
|
+
// Let the host intercept marker-sized pastes (e.g. the large-paste menu). When it takes
|
|
1815
|
+
// over, the editor inserts nothing and records no undo state — the host re-inserts via
|
|
1816
|
+
// `insertPaste`/`insertText` once the user chooses.
|
|
1817
|
+
if (isMarkerSized && this.onLargePaste?.(filteredText, pastedLines.length)) {
|
|
1818
|
+
return;
|
|
1819
|
+
}
|
|
1645
1820
|
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
? `[paste #${pasteId} +${pastedLines.length} lines]`
|
|
1650
|
-
: `[paste #${pasteId} ${totalChars} chars]`;
|
|
1651
|
-
this.#insertTextAtCursor(marker);
|
|
1821
|
+
this.#historyIndex = -1; // Exit history browsing mode
|
|
1822
|
+
this.#resetKillSequence();
|
|
1823
|
+
this.#recordUndoState();
|
|
1652
1824
|
|
|
1825
|
+
this.#withUndoSuspended(() => {
|
|
1826
|
+
if (isMarkerSized) {
|
|
1827
|
+
this.#storePasteMarker(filteredText, pastedLines.length);
|
|
1653
1828
|
return;
|
|
1654
1829
|
}
|
|
1655
1830
|
|
|
1656
1831
|
if (pastedLines.length === 1) {
|
|
1657
|
-
// Single line - insert
|
|
1658
|
-
|
|
1659
|
-
|
|
1832
|
+
// Single line - insert in one operation (per-char replay is O(paste × buffer)),
|
|
1833
|
+
// then evaluate autocomplete triggers once at the final cursor position.
|
|
1834
|
+
if (filteredText) {
|
|
1835
|
+
this.#insertTextAtCursor(filteredText);
|
|
1836
|
+
this.#retriggerAutocompleteAtCursor();
|
|
1660
1837
|
}
|
|
1661
1838
|
return;
|
|
1662
1839
|
}
|
|
@@ -1666,6 +1843,70 @@ export class Editor implements Component, Focusable {
|
|
|
1666
1843
|
});
|
|
1667
1844
|
}
|
|
1668
1845
|
|
|
1846
|
+
/** Normalize raw pasted text: decode tmux CSI-u re-encoded control bytes, normalize CRLF and
|
|
1847
|
+
* NFC (macOS NFD filename drag-drops), expand tabs, and strip control characters except newline. */
|
|
1848
|
+
#sanitizePastedText(pastedText: string): string {
|
|
1849
|
+
// Some terminals (e.g. tmux popups with extended-keys-format=csi-u) re-encode
|
|
1850
|
+
// control bytes inside bracketed paste as CSI-u Ctrl+<letter> sequences
|
|
1851
|
+
// (ESC [ <codepoint> ; 5 u). Decode those back to their literal byte so the
|
|
1852
|
+
// per-char filter below preserves newlines instead of stripping ESC and
|
|
1853
|
+
// leaking the printable tail (e.g. "[106;5u") into the editor.
|
|
1854
|
+
const decodedText = pastedText.replace(/\x1b\[(\d+);5u/g, (match, code) => {
|
|
1855
|
+
const cp = Number(code);
|
|
1856
|
+
if (cp >= 97 && cp <= 122) return String.fromCharCode(cp - 96);
|
|
1857
|
+
if (cp >= 65 && cp <= 90) return String.fromCharCode(cp - 64);
|
|
1858
|
+
return match;
|
|
1859
|
+
});
|
|
1860
|
+
|
|
1861
|
+
// Clean the pasted text. NFC-normalize so macOS Finder drag-drops of
|
|
1862
|
+
// Korean filenames (which arrive as NFD: e.g. `ᄒ`+`ᅪ` instead of `화`)
|
|
1863
|
+
// land in the buffer as the same precomposed syllables a terminal
|
|
1864
|
+
// renders — without this, cursor column accounting drifts by
|
|
1865
|
+
// `(NFD cells − NFC cells)` and the visible glyph desyncs from the
|
|
1866
|
+
// hardware cursor.
|
|
1867
|
+
const cleanText = decodedText.replace(/\r\n?/g, "\n").normalize("NFC");
|
|
1868
|
+
|
|
1869
|
+
// Convert tabs to spaces (4 spaces per tab).
|
|
1870
|
+
const tabExpandedText = cleanText.replace(/\t/g, " ");
|
|
1871
|
+
|
|
1872
|
+
// Strip control characters except newline (tabs already expanded above, CRs already
|
|
1873
|
+
// normalized). Single regex pass instead of split/filter/join to avoid allocating a
|
|
1874
|
+
// per-code-unit array for large pastes.
|
|
1875
|
+
return tabExpandedText.replace(/[\x00-\x09\x0B-\x1F]/g, "");
|
|
1876
|
+
}
|
|
1877
|
+
|
|
1878
|
+
/** Store `content` in the paste buffer and insert a collapsed `[Paste #N]` marker that expands
|
|
1879
|
+
* back to `content` on submit. `lineCount` is the content's line count. */
|
|
1880
|
+
#storePasteMarker(content: string, lineCount: number): void {
|
|
1881
|
+
this.#pasteCounter++;
|
|
1882
|
+
const pasteId = this.#pasteCounter;
|
|
1883
|
+
this.#pastes.set(pasteId, content);
|
|
1884
|
+
|
|
1885
|
+
// Insert marker like "[Paste #1, +123 lines]" or "[Paste #1, 1234 chars]".
|
|
1886
|
+
const marker =
|
|
1887
|
+
lineCount > 10 ? `[Paste #${pasteId}, +${lineCount} lines]` : `[Paste #${pasteId}, ${content.length} chars]`;
|
|
1888
|
+
this.#insertTextAtCursor(marker);
|
|
1889
|
+
}
|
|
1890
|
+
|
|
1891
|
+
/** Re-evaluate autocomplete triggers for the text ending at the cursor (used after bulk edits). */
|
|
1892
|
+
#retriggerAutocompleteAtCursor(): void {
|
|
1893
|
+
if (this.#autocompleteState) {
|
|
1894
|
+
this.#debouncedUpdateAutocomplete();
|
|
1895
|
+
return;
|
|
1896
|
+
}
|
|
1897
|
+
const currentLine = this.#state.lines[this.#state.cursorLine] || "";
|
|
1898
|
+
const textBeforeCursor = currentLine.slice(0, this.#state.cursorCol);
|
|
1899
|
+
if (this.#isInSubmittedSlashCommandContext()) {
|
|
1900
|
+
this.#tryTriggerAutocomplete();
|
|
1901
|
+
} else if (textBeforeCursor.match(/(?:^|[\s])@[^\s]*$/)) {
|
|
1902
|
+
this.#tryTriggerAutocomplete();
|
|
1903
|
+
} else if (textBeforeCursor.match(/#[^\s#]*$/)) {
|
|
1904
|
+
this.#tryTriggerAutocomplete();
|
|
1905
|
+
} else if (this.#textTriggersUrlAutocomplete(textBeforeCursor)) {
|
|
1906
|
+
this.#tryTriggerAutocomplete();
|
|
1907
|
+
}
|
|
1908
|
+
}
|
|
1909
|
+
|
|
1669
1910
|
#addNewLine(): void {
|
|
1670
1911
|
this.#historyIndex = -1; // Exit history browsing mode
|
|
1671
1912
|
this.#resetKillSequence();
|
|
@@ -1716,26 +1957,88 @@ export class Editor implements Component, Focusable {
|
|
|
1716
1957
|
if (this.onSubmit) this.onSubmit(result);
|
|
1717
1958
|
}
|
|
1718
1959
|
|
|
1960
|
+
/** Resolve the compiled, global copy of `atomicTokenPattern`, rebuilt only when the source changes. */
|
|
1961
|
+
#getAtomicTokenRe(): RegExp | undefined {
|
|
1962
|
+
const pattern = this.atomicTokenPattern;
|
|
1963
|
+
if (pattern === undefined) {
|
|
1964
|
+
this.#atomicTokenSource = undefined;
|
|
1965
|
+
this.#atomicTokenRe = undefined;
|
|
1966
|
+
return undefined;
|
|
1967
|
+
}
|
|
1968
|
+
if (pattern.source !== this.#atomicTokenSource) {
|
|
1969
|
+
this.#atomicTokenSource = pattern.source;
|
|
1970
|
+
this.#atomicTokenRe = new RegExp(
|
|
1971
|
+
pattern.source,
|
|
1972
|
+
pattern.flags.includes("g") ? pattern.flags : `${pattern.flags}g`,
|
|
1973
|
+
);
|
|
1974
|
+
}
|
|
1975
|
+
return this.#atomicTokenRe;
|
|
1976
|
+
}
|
|
1977
|
+
|
|
1978
|
+
/** Find an atomic token on `line` whose span contains column `col` (`start <= col < end`). */
|
|
1979
|
+
#atomicTokenAt(line: string, col: number): { start: number; end: number } | undefined {
|
|
1980
|
+
const re = this.#getAtomicTokenRe();
|
|
1981
|
+
if (re === undefined) return undefined;
|
|
1982
|
+
re.lastIndex = 0;
|
|
1983
|
+
for (;;) {
|
|
1984
|
+
const match = re.exec(line);
|
|
1985
|
+
if (match === null) break;
|
|
1986
|
+
if (match[0].length === 0) {
|
|
1987
|
+
re.lastIndex = match.index + 1;
|
|
1988
|
+
continue;
|
|
1989
|
+
}
|
|
1990
|
+
const start = match.index;
|
|
1991
|
+
const end = start + match[0].length;
|
|
1992
|
+
if (col < start) break;
|
|
1993
|
+
if (col < end) return { start, end };
|
|
1994
|
+
}
|
|
1995
|
+
return undefined;
|
|
1996
|
+
}
|
|
1997
|
+
|
|
1998
|
+
/** Expand the half-open range [start, end) so it never cuts through an atomic
|
|
1999
|
+
* placeholder token: a boundary landing inside a token pulls the whole token in. */
|
|
2000
|
+
#expandRangeOverAtomicTokens(line: string, start: number, end: number): { start: number; end: number } {
|
|
2001
|
+
const startToken = this.#atomicTokenAt(line, start);
|
|
2002
|
+
if (startToken !== undefined && startToken.start < start) {
|
|
2003
|
+
start = startToken.start;
|
|
2004
|
+
}
|
|
2005
|
+
if (end > start) {
|
|
2006
|
+
const endToken = this.#atomicTokenAt(line, end - 1);
|
|
2007
|
+
if (endToken !== undefined && endToken.end > end) {
|
|
2008
|
+
end = endToken.end;
|
|
2009
|
+
}
|
|
2010
|
+
}
|
|
2011
|
+
return { start, end };
|
|
2012
|
+
}
|
|
2013
|
+
|
|
1719
2014
|
#handleBackspace(): void {
|
|
1720
2015
|
this.#historyIndex = -1; // Exit history browsing mode
|
|
1721
2016
|
this.#resetKillSequence();
|
|
1722
2017
|
this.#recordUndoState();
|
|
1723
2018
|
|
|
1724
2019
|
if (this.#state.cursorCol > 0) {
|
|
1725
|
-
// Delete grapheme before cursor (handles emojis, combining characters, etc.)
|
|
1726
2020
|
const line = this.#state.lines[this.#state.cursorLine] || "";
|
|
1727
|
-
|
|
2021
|
+
// An atomic placeholder token (image/paste marker) deletes as a unit, so a single
|
|
2022
|
+
// backspace never leaves a half-eaten `[Paste #1, +30 lines` behind as stray text.
|
|
2023
|
+
const token = this.#atomicTokenAt(line, this.#state.cursorCol - 1);
|
|
2024
|
+
if (token !== undefined) {
|
|
2025
|
+
this.#state.lines[this.#state.cursorLine] = line.slice(0, token.start) + line.slice(token.end);
|
|
2026
|
+
this.#setCursorCol(token.start);
|
|
2027
|
+
} else {
|
|
2028
|
+
// Delete grapheme before cursor (handles emojis, combining characters, etc.)
|
|
2029
|
+
const beforeCursor = line.slice(0, this.#state.cursorCol);
|
|
1728
2030
|
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
2031
|
+
// Find the last grapheme in the text before cursor
|
|
2032
|
+
const graphemes = [...segmenter.segment(beforeCursor)];
|
|
2033
|
+
const lastGrapheme = graphemes[graphemes.length - 1];
|
|
2034
|
+
const graphemeLength = lastGrapheme ? lastGrapheme.segment.length : 1;
|
|
1733
2035
|
|
|
1734
|
-
|
|
1735
|
-
|
|
2036
|
+
const before = line.slice(0, this.#state.cursorCol - graphemeLength);
|
|
2037
|
+
const after = line.slice(this.#state.cursorCol);
|
|
1736
2038
|
|
|
1737
|
-
|
|
1738
|
-
|
|
2039
|
+
this.#state.lines[this.#state.cursorLine] = before + after;
|
|
2040
|
+
this.#setCursorCol(this.#state.cursorCol - graphemeLength);
|
|
2041
|
+
}
|
|
1739
2042
|
} else if (this.#state.cursorLine > 0) {
|
|
1740
2043
|
// Merge with previous line
|
|
1741
2044
|
const currentLine = this.#state.lines[this.#state.cursorLine] || "";
|
|
@@ -1800,18 +2103,24 @@ export class Editor implements Component, Focusable {
|
|
|
1800
2103
|
const targetVL = visualLines[targetVisualLine];
|
|
1801
2104
|
|
|
1802
2105
|
if (currentVL && targetVL) {
|
|
1803
|
-
|
|
2106
|
+
// Work in visual cells (grapheme-walked), not UTF-16 code units: code-unit
|
|
2107
|
+
// columns land mid-surrogate on emoji and drift on wide CJK glyphs.
|
|
2108
|
+
const sourceLine = this.#state.lines[currentVL.logicalLine] || "";
|
|
2109
|
+
const sourceText = sourceLine.slice(currentVL.startCol, currentVL.startCol + currentVL.length);
|
|
2110
|
+
const currentVisualCol = visualColAtOffset(sourceText, this.#state.cursorCol - currentVL.startCol);
|
|
1804
2111
|
|
|
1805
|
-
// For non-last segments, clamp
|
|
2112
|
+
// For non-last segments, clamp before the segment end to stay within the segment
|
|
1806
2113
|
const isLastSourceSegment =
|
|
1807
2114
|
currentVisualLine === visualLines.length - 1 ||
|
|
1808
2115
|
visualLines[currentVisualLine + 1]?.logicalLine !== currentVL.logicalLine;
|
|
1809
|
-
const sourceMaxVisualCol =
|
|
2116
|
+
const sourceMaxVisualCol = maxSegmentVisualCol(sourceText, isLastSourceSegment);
|
|
1810
2117
|
|
|
1811
2118
|
const isLastTargetSegment =
|
|
1812
2119
|
targetVisualLine === visualLines.length - 1 ||
|
|
1813
2120
|
visualLines[targetVisualLine + 1]?.logicalLine !== targetVL.logicalLine;
|
|
1814
|
-
const
|
|
2121
|
+
const targetLine = this.#state.lines[targetVL.logicalLine] || "";
|
|
2122
|
+
const targetText = targetLine.slice(targetVL.startCol, targetVL.startCol + targetVL.length);
|
|
2123
|
+
const targetMaxVisualCol = maxSegmentVisualCol(targetText, isLastTargetSegment);
|
|
1815
2124
|
|
|
1816
2125
|
const moveToVisualCol = this.#computeVerticalMoveColumn(
|
|
1817
2126
|
currentVisualCol,
|
|
@@ -1819,11 +2128,10 @@ export class Editor implements Component, Focusable {
|
|
|
1819
2128
|
targetMaxVisualCol,
|
|
1820
2129
|
);
|
|
1821
2130
|
|
|
1822
|
-
// Set cursor position
|
|
2131
|
+
// Set cursor position, snapping to a grapheme boundary in the target text
|
|
1823
2132
|
this.#state.cursorLine = targetVL.logicalLine;
|
|
1824
|
-
const targetCol = targetVL.startCol + moveToVisualCol;
|
|
1825
|
-
|
|
1826
|
-
this.#state.cursorCol = Math.min(targetCol, logicalLine.length);
|
|
2133
|
+
const targetCol = targetVL.startCol + offsetAtVisualCol(targetText, moveToVisualCol);
|
|
2134
|
+
this.#state.cursorCol = Math.min(targetCol, targetLine.length);
|
|
1827
2135
|
}
|
|
1828
2136
|
}
|
|
1829
2137
|
|
|
@@ -1900,6 +2208,9 @@ export class Editor implements Component, Focusable {
|
|
|
1900
2208
|
#recordUndoState(): void {
|
|
1901
2209
|
if (this.#suspendUndo) return;
|
|
1902
2210
|
this.#undoStack.push(structuredClone(this.#state));
|
|
2211
|
+
if (this.#undoStack.length > MAX_UNDO_STACK) {
|
|
2212
|
+
this.#undoStack.shift();
|
|
2213
|
+
}
|
|
1903
2214
|
}
|
|
1904
2215
|
|
|
1905
2216
|
#applyUndo(): void {
|
|
@@ -2089,9 +2400,11 @@ export class Editor implements Component, Focusable {
|
|
|
2089
2400
|
let deletedText = "";
|
|
2090
2401
|
|
|
2091
2402
|
if (this.#state.cursorCol > 0) {
|
|
2092
|
-
// Delete from start of line up to cursor
|
|
2093
|
-
|
|
2094
|
-
this.#
|
|
2403
|
+
// Delete from start of line up to cursor, extending over any atomic token
|
|
2404
|
+
// the boundary would otherwise cut in half.
|
|
2405
|
+
const { end } = this.#expandRangeOverAtomicTokens(currentLine, 0, this.#state.cursorCol);
|
|
2406
|
+
deletedText = currentLine.slice(0, end);
|
|
2407
|
+
this.#state.lines[this.#state.cursorLine] = currentLine.slice(end);
|
|
2095
2408
|
this.#setCursorCol(0);
|
|
2096
2409
|
} else if (this.#state.cursorLine > 0) {
|
|
2097
2410
|
// At start of line - merge with previous line
|
|
@@ -2118,9 +2431,14 @@ export class Editor implements Component, Focusable {
|
|
|
2118
2431
|
let deletedText = "";
|
|
2119
2432
|
|
|
2120
2433
|
if (this.#state.cursorCol < currentLine.length) {
|
|
2121
|
-
// Delete from cursor to end of line
|
|
2122
|
-
|
|
2123
|
-
|
|
2434
|
+
// Delete from cursor to end of line, extending backwards over an atomic
|
|
2435
|
+
// token the cursor sits inside so no half-eaten marker text remains.
|
|
2436
|
+
const { start } = this.#expandRangeOverAtomicTokens(currentLine, this.#state.cursorCol, currentLine.length);
|
|
2437
|
+
deletedText = currentLine.slice(start);
|
|
2438
|
+
this.#state.lines[this.#state.cursorLine] = currentLine.slice(0, start);
|
|
2439
|
+
if (start < this.#state.cursorCol) {
|
|
2440
|
+
this.#setCursorCol(start);
|
|
2441
|
+
}
|
|
2124
2442
|
} else if (this.#state.cursorLine < this.#state.lines.length - 1) {
|
|
2125
2443
|
// At end of line - merge with next line
|
|
2126
2444
|
const nextLine = this.#state.lines[this.#state.cursorLine + 1] || "";
|
|
@@ -2155,13 +2473,13 @@ export class Editor implements Component, Focusable {
|
|
|
2155
2473
|
} else {
|
|
2156
2474
|
const oldCursorCol = this.#state.cursorCol;
|
|
2157
2475
|
this.#moveWordBackwards();
|
|
2158
|
-
|
|
2159
|
-
|
|
2476
|
+
// Extend the range over any atomic token it intersects so a word delete
|
|
2477
|
+
// never leaves half-eaten marker text behind.
|
|
2478
|
+
const range = this.#expandRangeOverAtomicTokens(currentLine, this.#state.cursorCol, oldCursorCol);
|
|
2160
2479
|
|
|
2161
|
-
const deletedText = currentLine.slice(
|
|
2162
|
-
this.#state.lines[this.#state.cursorLine] =
|
|
2163
|
-
|
|
2164
|
-
this.#setCursorCol(deleteFrom);
|
|
2480
|
+
const deletedText = currentLine.slice(range.start, range.end);
|
|
2481
|
+
this.#state.lines[this.#state.cursorLine] = currentLine.slice(0, range.start) + currentLine.slice(range.end);
|
|
2482
|
+
this.#setCursorCol(range.start);
|
|
2165
2483
|
this.#recordKill(deletedText, "backward");
|
|
2166
2484
|
}
|
|
2167
2485
|
|
|
@@ -2186,11 +2504,13 @@ export class Editor implements Component, Focusable {
|
|
|
2186
2504
|
} else {
|
|
2187
2505
|
const oldCursorCol = this.#state.cursorCol;
|
|
2188
2506
|
this.#moveWordForwards();
|
|
2189
|
-
|
|
2190
|
-
|
|
2507
|
+
// Extend the range over any atomic token it intersects so a word delete
|
|
2508
|
+
// never leaves half-eaten marker text behind.
|
|
2509
|
+
const range = this.#expandRangeOverAtomicTokens(currentLine, oldCursorCol, this.#state.cursorCol);
|
|
2191
2510
|
|
|
2192
|
-
const deletedText = currentLine.slice(
|
|
2193
|
-
this.#state.lines[this.#state.cursorLine] = currentLine.slice(0,
|
|
2511
|
+
const deletedText = currentLine.slice(range.start, range.end);
|
|
2512
|
+
this.#state.lines[this.#state.cursorLine] = currentLine.slice(0, range.start) + currentLine.slice(range.end);
|
|
2513
|
+
this.#setCursorCol(range.start);
|
|
2194
2514
|
this.#recordKill(deletedText, "forward");
|
|
2195
2515
|
}
|
|
2196
2516
|
|
|
@@ -2207,17 +2527,25 @@ export class Editor implements Component, Focusable {
|
|
|
2207
2527
|
const currentLine = this.#state.lines[this.#state.cursorLine] || "";
|
|
2208
2528
|
|
|
2209
2529
|
if (this.#state.cursorCol < currentLine.length) {
|
|
2210
|
-
//
|
|
2211
|
-
const
|
|
2530
|
+
// An atomic placeholder token (image/paste marker) deletes as a unit.
|
|
2531
|
+
const token = this.#atomicTokenAt(currentLine, this.#state.cursorCol);
|
|
2532
|
+
if (token !== undefined) {
|
|
2533
|
+
this.#state.lines[this.#state.cursorLine] =
|
|
2534
|
+
currentLine.slice(0, token.start) + currentLine.slice(token.end);
|
|
2535
|
+
this.#setCursorCol(token.start);
|
|
2536
|
+
} else {
|
|
2537
|
+
// Delete grapheme at cursor position (handles emojis, combining characters, etc.)
|
|
2538
|
+
const afterCursor = currentLine.slice(this.#state.cursorCol);
|
|
2212
2539
|
|
|
2213
|
-
|
|
2214
|
-
|
|
2215
|
-
|
|
2216
|
-
|
|
2540
|
+
// Find the first grapheme at cursor
|
|
2541
|
+
const graphemes = [...segmenter.segment(afterCursor)];
|
|
2542
|
+
const firstGrapheme = graphemes[0];
|
|
2543
|
+
const graphemeLength = firstGrapheme ? firstGrapheme.segment.length : 1;
|
|
2217
2544
|
|
|
2218
|
-
|
|
2219
|
-
|
|
2220
|
-
|
|
2545
|
+
const before = currentLine.slice(0, this.#state.cursorCol);
|
|
2546
|
+
const after = currentLine.slice(this.#state.cursorCol + graphemeLength);
|
|
2547
|
+
this.#state.lines[this.#state.cursorLine] = before + after;
|
|
2548
|
+
}
|
|
2221
2549
|
} else if (this.#state.cursorLine < this.#state.lines.length - 1) {
|
|
2222
2550
|
// At end of line - merge with next line
|
|
2223
2551
|
const nextLine = this.#state.lines[this.#state.cursorLine + 1] || "";
|
|
@@ -2274,7 +2602,7 @@ export class Editor implements Component, Focusable {
|
|
|
2274
2602
|
visualLines.push({ logicalLine: i, startCol: 0, length: line.length });
|
|
2275
2603
|
} else {
|
|
2276
2604
|
// Line needs wrapping - use word-aware wrapping
|
|
2277
|
-
const chunks =
|
|
2605
|
+
const chunks = this.#wrapLine(line, width);
|
|
2278
2606
|
for (const chunk of chunks) {
|
|
2279
2607
|
visualLines.push({
|
|
2280
2608
|
logicalLine: i,
|
|
@@ -2299,9 +2627,15 @@ export class Editor implements Component, Focusable {
|
|
|
2299
2627
|
const colInSegment = this.#state.cursorCol - vl.startCol;
|
|
2300
2628
|
// Cursor is in this segment if it's within range
|
|
2301
2629
|
// For the last segment of a logical line, cursor can be at length (end position)
|
|
2630
|
+
// The first segment also owns any leading whitespace the wrapper skipped
|
|
2631
|
+
// (its startCol can be > 0), so a negative colInSegment maps there.
|
|
2302
2632
|
const isLastSegmentOfLine =
|
|
2303
2633
|
i === visualLines.length - 1 || visualLines[i + 1]?.logicalLine !== vl.logicalLine;
|
|
2304
|
-
|
|
2634
|
+
const isFirstSegmentOfLine = i === 0 || visualLines[i - 1]?.logicalLine !== vl.logicalLine;
|
|
2635
|
+
if (
|
|
2636
|
+
(colInSegment >= 0 || isFirstSegmentOfLine) &&
|
|
2637
|
+
(colInSegment < vl.length || (isLastSegmentOfLine && colInSegment <= vl.length))
|
|
2638
|
+
) {
|
|
2305
2639
|
return i;
|
|
2306
2640
|
}
|
|
2307
2641
|
}
|
|
@@ -2341,7 +2675,8 @@ export class Editor implements Component, Focusable {
|
|
|
2341
2675
|
// At end of last line - can't move, but set preferredVisualCol for up/down navigation
|
|
2342
2676
|
const currentVL = visualLines[currentVisualLine];
|
|
2343
2677
|
if (currentVL) {
|
|
2344
|
-
|
|
2678
|
+
const segmentText = currentLine.slice(currentVL.startCol, currentVL.startCol + currentVL.length);
|
|
2679
|
+
this.#preferredVisualCol = visualColAtOffset(segmentText, this.#state.cursorCol - currentVL.startCol);
|
|
2345
2680
|
}
|
|
2346
2681
|
}
|
|
2347
2682
|
} else {
|