@oh-my-pi/pi-tui 11.2.3 → 11.3.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oh-my-pi/pi-tui",
3
- "version": "11.2.3",
3
+ "version": "11.3.0",
4
4
  "description": "Terminal User Interface library with differential rendering for efficient text-based applications",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -47,8 +47,8 @@
47
47
  "bun": ">=1.3.7"
48
48
  },
49
49
  "dependencies": {
50
- "@oh-my-pi/pi-natives": "11.2.3",
51
- "@oh-my-pi/pi-utils": "11.2.3",
50
+ "@oh-my-pi/pi-natives": "11.3.0",
51
+ "@oh-my-pi/pi-utils": "11.3.0",
52
52
  "@types/mime-types": "^3.0.1",
53
53
  "chalk": "^5.6.2",
54
54
  "marked": "^17.0.1",
@@ -519,6 +519,10 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
519
519
  if (!entry.name.toLowerCase().startsWith(searchPrefix.toLowerCase())) {
520
520
  continue;
521
521
  }
522
+ // Skip .git directory
523
+ if (entry.name === ".git") {
524
+ continue;
525
+ }
522
526
 
523
527
  // Check if entry is a directory (or a symlink pointing to a directory)
524
528
  let isDirectory = entry.isDirectory();
@@ -627,11 +631,19 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
627
631
  query,
628
632
  path: this.basePath,
629
633
  maxResults: 100,
630
- hidden: false,
634
+ hidden: true,
631
635
  gitignore: true,
632
636
  });
633
637
 
634
- const scoredEntries = result.matches
638
+ const filteredMatches = result.matches.filter(entry => {
639
+ const p = entry.path.endsWith("/") ? entry.path.slice(0, -1) : entry.path;
640
+ const normalized = p.replaceAll("\\", "/");
641
+ // Exclude the `.git` directory (including when the path ends with `/.git`).
642
+ // Must only match a full path segment to avoid excluding `.gitignore`, `foo.git`, etc.
643
+ return !/(^|\/)\.git(\/|$)/.test(normalized);
644
+ });
645
+
646
+ const scoredEntries = filteredMatches
635
647
  .map(entry => ({
636
648
  path: entry.path,
637
649
  isDirectory: entry.isDirectory,
@@ -51,8 +51,18 @@ export class Box implements Component {
51
51
  this.cached = undefined;
52
52
  }
53
53
 
54
+ private static _tmp = new Uint32Array(2);
54
55
  private computeCacheKey(width: number, childLines: string[], bgSample: string | undefined): bigint {
55
- return Bun.hash.xxHash64(JSON.stringify([width, bgSample, childLines]));
56
+ Box._tmp[0] = width;
57
+ Box._tmp[1] = childLines.length;
58
+ let h = Bun.hash.xxHash64(Box._tmp);
59
+ for (const line of childLines) {
60
+ Box._tmp[0] = line.length;
61
+ h = Bun.hash.xxHash64(Box._tmp, h);
62
+ h = Bun.hash.xxHash64(line, h);
63
+ }
64
+ h = Bun.hash.xxHash64(bgSample ?? "", h);
65
+ return h;
56
66
  }
57
67
 
58
68
  private matchCache(cacheKey: bigint): boolean {
@@ -306,6 +306,9 @@ export class Editor implements Component, Focusable {
306
306
  // Character jump mode
307
307
  private jumpMode: "forward" | "backward" | null = null;
308
308
 
309
+ // Preferred visual column for vertical cursor movement (sticky column)
310
+ private preferredVisualCol: number | null = null;
311
+
309
312
  // Border color (can be changed dynamically)
310
313
  public borderColor: (str: string) => string;
311
314
 
@@ -455,7 +458,7 @@ export class Editor implements Component, Focusable {
455
458
  const lines = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n");
456
459
  this.state.lines = lines.length === 0 ? [""] : lines;
457
460
  this.state.cursorLine = this.state.lines.length - 1;
458
- this.state.cursorCol = this.state.lines[this.state.cursorLine]?.length || 0;
461
+ this.setCursorCol(this.state.lines[this.state.cursorLine]?.length || 0);
459
462
 
460
463
  if (this.onChange) {
461
464
  this.onChange(this.getText());
@@ -616,42 +619,6 @@ export class Editor implements Component, Focusable {
616
619
  return result;
617
620
  }
618
621
 
619
- getCursorPosition(width: number): { row: number; col: number } | null {
620
- if (!this.useTerminalCursor) return null;
621
-
622
- const paddingX = this.getEditorPaddingX();
623
- const borderWidth = paddingX + 1;
624
- const layoutWidth = this.getLayoutWidth(width, paddingX);
625
- if (layoutWidth <= 0) return null;
626
-
627
- const layoutLines = this.layoutText(layoutWidth);
628
- const visibleContentHeight = this.getVisibleContentHeight(layoutLines.length);
629
- this.updateScrollOffset(layoutWidth, layoutLines, visibleContentHeight);
630
-
631
- for (let i = 0; i < layoutLines.length; i++) {
632
- if (i < this.scrollOffset || i >= this.scrollOffset + visibleContentHeight) continue;
633
- const layoutLine = layoutLines[i];
634
- if (!layoutLine || !layoutLine.hasCursor || layoutLine.cursorPos === undefined) continue;
635
-
636
- const lineWidth = visibleWidth(layoutLine.text);
637
- const isCursorAtLineEnd = layoutLine.cursorPos === layoutLine.text.length;
638
-
639
- if (isCursorAtLineEnd && lineWidth >= layoutWidth && layoutLine.text.length > 0) {
640
- const graphemes = [...segmenter.segment(layoutLine.text)];
641
- const lastGrapheme = graphemes[graphemes.length - 1]?.segment || "";
642
- const lastWidth = visibleWidth(lastGrapheme) || 1;
643
- const colOffset = borderWidth + Math.max(0, lineWidth - lastWidth);
644
- return { row: 1 + i - this.scrollOffset, col: colOffset };
645
- }
646
-
647
- const before = layoutLine.text.slice(0, layoutLine.cursorPos);
648
- const colOffset = borderWidth + visibleWidth(before);
649
- return { row: 1 + i - this.scrollOffset, col: colOffset };
650
- }
651
-
652
- return null;
653
- }
654
-
655
622
  handleInput(data: string): void {
656
623
  const kb = getEditorKeybindings();
657
624
 
@@ -768,7 +735,7 @@ export class Editor implements Component, Focusable {
768
735
 
769
736
  this.state.lines = result.lines;
770
737
  this.state.cursorLine = result.cursorLine;
771
- this.state.cursorCol = result.cursorCol;
738
+ this.setCursorCol(result.cursorCol);
772
739
 
773
740
  this.cancelAutocomplete();
774
741
 
@@ -803,7 +770,7 @@ export class Editor implements Component, Focusable {
803
770
 
804
771
  this.state.lines = result.lines;
805
772
  this.state.cursorLine = result.cursorLine;
806
- this.state.cursorCol = result.cursorCol;
773
+ this.setCursorCol(result.cursorCol);
807
774
  }
808
775
  this.cancelAutocomplete();
809
776
  }
@@ -823,7 +790,7 @@ export class Editor implements Component, Focusable {
823
790
 
824
791
  this.state.lines = result.lines;
825
792
  this.state.cursorLine = result.cursorLine;
826
- this.state.cursorCol = result.cursorCol;
793
+ this.setCursorCol(result.cursorCol);
827
794
 
828
795
  this.cancelAutocomplete();
829
796
 
@@ -885,7 +852,7 @@ export class Editor implements Component, Focusable {
885
852
  this.addNewLine();
886
853
  }
887
854
  }
888
- // New line shortcuts (but not plain LF/CR which should be submit)
855
+ // New line
889
856
  else if (
890
857
  (data.charCodeAt(0) === 10 && data.length > 1) || // Ctrl+Enter with modifiers
891
858
  data === "\x1b[13;5u" || // Ctrl+Enter (Kitty protocol)
@@ -896,7 +863,11 @@ export class Editor implements Component, Focusable {
896
863
  (data.length > 1 && data.includes("\x1b") && data.includes("\r")) ||
897
864
  (data === "\n" && data.length === 1) // Shift+Enter from iTerm2 mapping
898
865
  ) {
899
- // Modifier + Enter = new line
866
+ if (this.shouldSubmitOnBackslashEnter(data, kb)) {
867
+ this.handleBackspace();
868
+ this.submitValue();
869
+ return;
870
+ }
900
871
  this.addNewLine();
901
872
  }
902
873
  // Plain Enter - submit (handles both legacy \r and Kitty protocol with lock bits)
@@ -906,46 +877,7 @@ export class Editor implements Component, Focusable {
906
877
  return;
907
878
  }
908
879
 
909
- // Workaround for terminals without Shift+Enter support:
910
- // If char before cursor is \, delete it and insert newline instead of submitting.
911
- const currentLine = this.state.lines[this.state.cursorLine] || "";
912
- if (this.state.cursorCol > 0 && currentLine[this.state.cursorCol - 1] === "\\") {
913
- this.handleBackspace();
914
- this.addNewLine();
915
- return;
916
- }
917
-
918
- this.resetKillSequence();
919
-
920
- // Get text and substitute paste markers with actual content
921
- let result = this.state.lines.join("\n").trim();
922
-
923
- // Replace all [paste #N +xxx lines] or [paste #N xxx chars] markers with actual paste content
924
- for (const [pasteId, pasteContent] of this.pastes) {
925
- // Match formats: [paste #N], [paste #N +xxx lines], or [paste #N xxx chars]
926
- const markerRegex = new RegExp(`\\[paste #${pasteId}( (\\+\\d+ lines|\\d+ chars))?\\]`, "g");
927
- result = result.replace(markerRegex, pasteContent);
928
- }
929
-
930
- // Reset editor and clear pastes
931
- this.state = {
932
- lines: [""],
933
- cursorLine: 0,
934
- cursorCol: 0,
935
- };
936
- this.clearUndoStack();
937
- this.pastes.clear();
938
- this.pasteCounter = 0;
939
- this.historyIndex = -1; // Exit history browsing mode
940
-
941
- // Notify that editor is now empty
942
- if (this.onChange) {
943
- this.onChange("");
944
- }
945
-
946
- if (this.onSubmit) {
947
- this.onSubmit(result);
948
- }
880
+ this.submitValue();
949
881
  }
950
882
  // Backspace (including Shift+Backspace)
951
883
  else if (matchesKey(data, "backspace") || matchesKey(data, "shift+backspace")) {
@@ -1155,7 +1087,7 @@ export class Editor implements Component, Focusable {
1155
1087
  const after = line.slice(this.state.cursorCol);
1156
1088
 
1157
1089
  this.state.lines[this.state.cursorLine] = before + text + after;
1158
- this.state.cursorCol += text.length;
1090
+ this.setCursorCol(this.state.cursorCol + text.length);
1159
1091
 
1160
1092
  if (this.onChange) {
1161
1093
  this.onChange(this.getText());
@@ -1174,7 +1106,7 @@ export class Editor implements Component, Focusable {
1174
1106
  const after = line.slice(this.state.cursorCol);
1175
1107
 
1176
1108
  this.state.lines[this.state.cursorLine] = before + char + after;
1177
- this.state.cursorCol += char.length; // Fix: increment by the length of the inserted string
1109
+ this.setCursorCol(this.state.cursorCol + char.length);
1178
1110
 
1179
1111
  if (this.onChange) {
1180
1112
  this.onChange(this.getText());
@@ -1292,13 +1224,44 @@ export class Editor implements Component, Focusable {
1292
1224
 
1293
1225
  // Move cursor to start of new line
1294
1226
  this.state.cursorLine++;
1295
- this.state.cursorCol = 0;
1227
+ this.setCursorCol(0);
1296
1228
 
1297
1229
  if (this.onChange) {
1298
1230
  this.onChange(this.getText());
1299
1231
  }
1300
1232
  }
1301
1233
 
1234
+ private shouldSubmitOnBackslashEnter(data: string, kb: ReturnType<typeof getEditorKeybindings>): boolean {
1235
+ if (this.disableSubmit) return false;
1236
+ if (!matchesKey(data, "enter")) return false;
1237
+ const submitKeys = kb.getKeys("submit");
1238
+ const hasShiftEnter = submitKeys.includes("shift+enter") || submitKeys.includes("shift+return");
1239
+ if (!hasShiftEnter) return false;
1240
+
1241
+ const currentLine = this.state.lines[this.state.cursorLine] || "";
1242
+ return this.state.cursorCol > 0 && currentLine[this.state.cursorCol - 1] === "\\";
1243
+ }
1244
+
1245
+ private submitValue(): void {
1246
+ this.resetKillSequence();
1247
+
1248
+ let result = this.state.lines.join("\n").trim();
1249
+ for (const [pasteId, pasteContent] of this.pastes) {
1250
+ const markerRegex = new RegExp(`\\[paste #${pasteId}( (\\+\\d+ lines|\\d+ chars))?\\]`, "g");
1251
+ result = result.replace(markerRegex, pasteContent);
1252
+ }
1253
+
1254
+ this.state = { lines: [""], cursorLine: 0, cursorCol: 0 };
1255
+ this.pastes.clear();
1256
+ this.pasteCounter = 0;
1257
+ this.historyIndex = -1;
1258
+ this.scrollOffset = 0;
1259
+ this.undoStack.length = 0;
1260
+
1261
+ if (this.onChange) this.onChange("");
1262
+ if (this.onSubmit) this.onSubmit(result);
1263
+ }
1264
+
1302
1265
  private handleBackspace(): void {
1303
1266
  this.historyIndex = -1; // Exit history browsing mode
1304
1267
  this.resetKillSequence();
@@ -1318,7 +1281,7 @@ export class Editor implements Component, Focusable {
1318
1281
  const after = line.slice(this.state.cursorCol);
1319
1282
 
1320
1283
  this.state.lines[this.state.cursorLine] = before + after;
1321
- this.state.cursorCol -= graphemeLength;
1284
+ this.setCursorCol(this.state.cursorCol - graphemeLength);
1322
1285
  } else if (this.state.cursorLine > 0) {
1323
1286
  // Merge with previous line
1324
1287
  const currentLine = this.state.lines[this.state.cursorLine] || "";
@@ -1328,7 +1291,7 @@ export class Editor implements Component, Focusable {
1328
1291
  this.state.lines.splice(this.state.cursorLine, 1);
1329
1292
 
1330
1293
  this.state.cursorLine--;
1331
- this.state.cursorCol = previousLine.length;
1294
+ this.setCursorCol(previousLine.length);
1332
1295
  }
1333
1296
 
1334
1297
  if (this.onChange) {
@@ -1353,15 +1316,96 @@ export class Editor implements Component, Focusable {
1353
1316
  }
1354
1317
  }
1355
1318
 
1319
+ /**
1320
+ * Set cursor column and clear preferredVisualCol.
1321
+ * Use this for all non-vertical cursor movements to reset sticky column behavior.
1322
+ */
1323
+ private setCursorCol(col: number): void {
1324
+ this.state.cursorCol = col;
1325
+ this.preferredVisualCol = null;
1326
+ }
1327
+
1328
+ /**
1329
+ * Move cursor to a target visual line, applying sticky column logic.
1330
+ * Shared by moveCursor() and pageScroll().
1331
+ */
1332
+ private moveToVisualLine(
1333
+ visualLines: Array<{ logicalLine: number; startCol: number; length: number }>,
1334
+ currentVisualLine: number,
1335
+ targetVisualLine: number,
1336
+ ): void {
1337
+ const currentVL = visualLines[currentVisualLine];
1338
+ const targetVL = visualLines[targetVisualLine];
1339
+
1340
+ if (currentVL && targetVL) {
1341
+ const currentVisualCol = this.state.cursorCol - currentVL.startCol;
1342
+
1343
+ // For non-last segments, clamp to length-1 to stay within the segment
1344
+ const isLastSourceSegment =
1345
+ currentVisualLine === visualLines.length - 1 ||
1346
+ visualLines[currentVisualLine + 1]?.logicalLine !== currentVL.logicalLine;
1347
+ const sourceMaxVisualCol = isLastSourceSegment ? currentVL.length : Math.max(0, currentVL.length - 1);
1348
+
1349
+ const isLastTargetSegment =
1350
+ targetVisualLine === visualLines.length - 1 ||
1351
+ visualLines[targetVisualLine + 1]?.logicalLine !== targetVL.logicalLine;
1352
+ const targetMaxVisualCol = isLastTargetSegment ? targetVL.length : Math.max(0, targetVL.length - 1);
1353
+
1354
+ const moveToVisualCol = this.computeVerticalMoveColumn(
1355
+ currentVisualCol,
1356
+ sourceMaxVisualCol,
1357
+ targetMaxVisualCol,
1358
+ );
1359
+
1360
+ // Set cursor position
1361
+ this.state.cursorLine = targetVL.logicalLine;
1362
+ const targetCol = targetVL.startCol + moveToVisualCol;
1363
+ const logicalLine = this.state.lines[targetVL.logicalLine] || "";
1364
+ this.state.cursorCol = Math.min(targetCol, logicalLine.length);
1365
+ }
1366
+ }
1367
+
1368
+ /**
1369
+ * Compute the target visual column for vertical cursor movement.
1370
+ * Implements the sticky column decision table.
1371
+ */
1372
+ private computeVerticalMoveColumn(
1373
+ currentVisualCol: number,
1374
+ sourceMaxVisualCol: number,
1375
+ targetMaxVisualCol: number,
1376
+ ): number {
1377
+ const hasPreferred = this.preferredVisualCol !== null;
1378
+ const cursorInMiddle = currentVisualCol < sourceMaxVisualCol;
1379
+ const targetTooShort = targetMaxVisualCol < currentVisualCol;
1380
+
1381
+ if (!hasPreferred || cursorInMiddle) {
1382
+ if (targetTooShort) {
1383
+ this.preferredVisualCol = currentVisualCol;
1384
+ return targetMaxVisualCol;
1385
+ }
1386
+ this.preferredVisualCol = null;
1387
+ return currentVisualCol;
1388
+ }
1389
+
1390
+ const targetCantFitPreferred = targetMaxVisualCol < this.preferredVisualCol!;
1391
+ if (targetTooShort || targetCantFitPreferred) {
1392
+ return targetMaxVisualCol;
1393
+ }
1394
+
1395
+ const result = this.preferredVisualCol!;
1396
+ this.preferredVisualCol = null;
1397
+ return result;
1398
+ }
1399
+
1356
1400
  private moveToLineStart(): void {
1357
1401
  this.resetKillSequence();
1358
- this.state.cursorCol = 0;
1402
+ this.setCursorCol(0);
1359
1403
  }
1360
1404
 
1361
1405
  private moveToLineEnd(): void {
1362
1406
  this.resetKillSequence();
1363
1407
  const currentLine = this.state.lines[this.state.cursorLine] || "";
1364
- this.state.cursorCol = currentLine.length;
1408
+ this.setCursorCol(currentLine.length);
1365
1409
  }
1366
1410
 
1367
1411
  private resetKillSequence(): void {
@@ -1412,6 +1456,7 @@ export class Editor implements Component, Focusable {
1412
1456
 
1413
1457
  this.historyIndex = -1;
1414
1458
  this.resetKillSequence();
1459
+ this.preferredVisualCol = null;
1415
1460
  this.state = {
1416
1461
  lines: [...snapshot.lines],
1417
1462
  cursorLine: snapshot.cursorLine,
@@ -1462,7 +1507,7 @@ export class Editor implements Component, Focusable {
1462
1507
  const before = line.slice(0, this.state.cursorCol);
1463
1508
  const after = line.slice(this.state.cursorCol);
1464
1509
  this.state.lines[this.state.cursorLine] = before + normalized + after;
1465
- this.state.cursorCol += normalized.length;
1510
+ this.setCursorCol(this.state.cursorCol + normalized.length);
1466
1511
  } else {
1467
1512
  const currentLine = this.state.lines[this.state.cursorLine] || "";
1468
1513
  const beforeCursor = currentLine.slice(0, this.state.cursorCol);
@@ -1485,7 +1530,7 @@ export class Editor implements Component, Focusable {
1485
1530
 
1486
1531
  this.state.lines = newLines;
1487
1532
  this.state.cursorLine += lines.length - 1;
1488
- this.state.cursorCol = (lines[lines.length - 1] || "").length;
1533
+ this.setCursorCol((lines[lines.length - 1] || "").length);
1489
1534
  }
1490
1535
 
1491
1536
  if (this.onChange) {
@@ -1509,7 +1554,7 @@ export class Editor implements Component, Focusable {
1509
1554
  // Delete from start of line up to cursor
1510
1555
  deletedText = currentLine.slice(0, this.state.cursorCol);
1511
1556
  this.state.lines[this.state.cursorLine] = currentLine.slice(this.state.cursorCol);
1512
- this.state.cursorCol = 0;
1557
+ this.setCursorCol(0);
1513
1558
  } else if (this.state.cursorLine > 0) {
1514
1559
  // At start of line - merge with previous line
1515
1560
  deletedText = "\n";
@@ -1517,7 +1562,7 @@ export class Editor implements Component, Focusable {
1517
1562
  this.state.lines[this.state.cursorLine - 1] = previousLine + currentLine;
1518
1563
  this.state.lines.splice(this.state.cursorLine, 1);
1519
1564
  this.state.cursorLine--;
1520
- this.state.cursorCol = previousLine.length;
1565
+ this.setCursorCol(previousLine.length);
1521
1566
  }
1522
1567
 
1523
1568
  this.recordKill(deletedText, "backward");
@@ -1567,18 +1612,18 @@ export class Editor implements Component, Focusable {
1567
1612
  this.state.lines[this.state.cursorLine - 1] = previousLine + currentLine;
1568
1613
  this.state.lines.splice(this.state.cursorLine, 1);
1569
1614
  this.state.cursorLine--;
1570
- this.state.cursorCol = previousLine.length;
1615
+ this.setCursorCol(previousLine.length);
1571
1616
  }
1572
1617
  } else {
1573
1618
  const oldCursorCol = this.state.cursorCol;
1574
1619
  this.moveWordBackwards();
1575
1620
  const deleteFrom = this.state.cursorCol;
1576
- this.state.cursorCol = oldCursorCol;
1621
+ this.setCursorCol(oldCursorCol);
1577
1622
 
1578
1623
  const deletedText = currentLine.slice(deleteFrom, oldCursorCol);
1579
1624
  this.state.lines[this.state.cursorLine] =
1580
1625
  currentLine.slice(0, deleteFrom) + currentLine.slice(this.state.cursorCol);
1581
- this.state.cursorCol = deleteFrom;
1626
+ this.setCursorCol(deleteFrom);
1582
1627
  this.recordKill(deletedText, "backward");
1583
1628
  }
1584
1629
 
@@ -1604,7 +1649,7 @@ export class Editor implements Component, Focusable {
1604
1649
  const oldCursorCol = this.state.cursorCol;
1605
1650
  this.moveWordForwards();
1606
1651
  const deleteTo = this.state.cursorCol;
1607
- this.state.cursorCol = oldCursorCol;
1652
+ this.setCursorCol(oldCursorCol);
1608
1653
 
1609
1654
  const deletedText = currentLine.slice(oldCursorCol, deleteTo);
1610
1655
  this.state.lines[this.state.cursorLine] = currentLine.slice(0, oldCursorCol) + currentLine.slice(deleteTo);
@@ -1723,29 +1768,14 @@ export class Editor implements Component, Focusable {
1723
1768
 
1724
1769
  private moveCursor(deltaLine: number, deltaCol: number): void {
1725
1770
  this.resetKillSequence();
1726
- const contentWidth = this.lastLayoutWidth;
1771
+ const visualLines = this.buildVisualLineMap(this.lastLayoutWidth);
1772
+ const currentVisualLine = this.findCurrentVisualLine(visualLines);
1727
1773
 
1728
1774
  if (deltaLine !== 0) {
1729
- // Build visual line map for navigation
1730
- const visualLines = this.buildVisualLineMap(contentWidth);
1731
- const currentVisualLine = this.findCurrentVisualLine(visualLines);
1732
-
1733
- // Calculate column position within current visual line
1734
- const currentVL = visualLines[currentVisualLine];
1735
- const visualCol = currentVL ? this.state.cursorCol - currentVL.startCol : 0;
1736
-
1737
- // Move to target visual line
1738
1775
  const targetVisualLine = currentVisualLine + deltaLine;
1739
1776
 
1740
1777
  if (targetVisualLine >= 0 && targetVisualLine < visualLines.length) {
1741
- const targetVL = visualLines[targetVisualLine];
1742
- if (targetVL) {
1743
- this.state.cursorLine = targetVL.logicalLine;
1744
- // Try to maintain visual column position, clamped to line length
1745
- const targetCol = targetVL.startCol + Math.min(visualCol, targetVL.length);
1746
- const logicalLine = this.state.lines[targetVL.logicalLine] || "";
1747
- this.state.cursorCol = Math.min(targetCol, logicalLine.length);
1748
- }
1778
+ this.moveToVisualLine(visualLines, currentVisualLine, targetVisualLine);
1749
1779
  }
1750
1780
  }
1751
1781
 
@@ -1758,11 +1788,17 @@ export class Editor implements Component, Focusable {
1758
1788
  const afterCursor = currentLine.slice(this.state.cursorCol);
1759
1789
  const graphemes = [...segmenter.segment(afterCursor)];
1760
1790
  const firstGrapheme = graphemes[0];
1761
- this.state.cursorCol += firstGrapheme ? firstGrapheme.segment.length : 1;
1791
+ this.setCursorCol(this.state.cursorCol + (firstGrapheme ? firstGrapheme.segment.length : 1));
1762
1792
  } else if (this.state.cursorLine < this.state.lines.length - 1) {
1763
1793
  // Wrap to start of next logical line
1764
1794
  this.state.cursorLine++;
1765
- this.state.cursorCol = 0;
1795
+ this.setCursorCol(0);
1796
+ } else {
1797
+ // At end of last line - can't move, but set preferredVisualCol for up/down navigation
1798
+ const currentVL = visualLines[currentVisualLine];
1799
+ if (currentVL) {
1800
+ this.preferredVisualCol = this.state.cursorCol - currentVL.startCol;
1801
+ }
1766
1802
  }
1767
1803
  } else {
1768
1804
  // Moving left - move by one grapheme (handles emojis, combining characters, etc.)
@@ -1770,12 +1806,12 @@ export class Editor implements Component, Focusable {
1770
1806
  const beforeCursor = currentLine.slice(0, this.state.cursorCol);
1771
1807
  const graphemes = [...segmenter.segment(beforeCursor)];
1772
1808
  const lastGrapheme = graphemes[graphemes.length - 1];
1773
- this.state.cursorCol -= lastGrapheme ? lastGrapheme.segment.length : 1;
1809
+ this.setCursorCol(this.state.cursorCol - (lastGrapheme ? lastGrapheme.segment.length : 1));
1774
1810
  } else if (this.state.cursorLine > 0) {
1775
1811
  // Wrap to end of previous logical line
1776
1812
  this.state.cursorLine--;
1777
1813
  const prevLine = this.state.lines[this.state.cursorLine] || "";
1778
- this.state.cursorCol = prevLine.length;
1814
+ this.setCursorCol(prevLine.length);
1779
1815
  }
1780
1816
  }
1781
1817
  }
@@ -1789,7 +1825,7 @@ export class Editor implements Component, Focusable {
1789
1825
  if (this.state.cursorLine > 0) {
1790
1826
  this.state.cursorLine--;
1791
1827
  const prevLine = this.state.lines[this.state.cursorLine] || "";
1792
- this.state.cursorCol = prevLine.length;
1828
+ this.setCursorCol(prevLine.length);
1793
1829
  }
1794
1830
  return;
1795
1831
  }
@@ -1822,7 +1858,7 @@ export class Editor implements Component, Focusable {
1822
1858
  }
1823
1859
  }
1824
1860
 
1825
- this.state.cursorCol = newCol;
1861
+ this.setCursorCol(newCol);
1826
1862
  }
1827
1863
 
1828
1864
  /**
@@ -1852,7 +1888,7 @@ export class Editor implements Component, Focusable {
1852
1888
 
1853
1889
  if (idx !== -1) {
1854
1890
  this.state.cursorLine = lineIdx;
1855
- this.state.cursorCol = idx;
1891
+ this.setCursorCol(idx);
1856
1892
  return;
1857
1893
  }
1858
1894
  }
@@ -1866,7 +1902,7 @@ export class Editor implements Component, Focusable {
1866
1902
  if (this.state.cursorCol >= currentLine.length) {
1867
1903
  if (this.state.cursorLine < this.state.lines.length - 1) {
1868
1904
  this.state.cursorLine++;
1869
- this.state.cursorCol = 0;
1905
+ this.setCursorCol(0);
1870
1906
  }
1871
1907
  return;
1872
1908
  }
@@ -1875,10 +1911,11 @@ export class Editor implements Component, Focusable {
1875
1911
  const segments = segmenter.segment(textAfterCursor);
1876
1912
  const iterator = segments[Symbol.iterator]();
1877
1913
  let next = iterator.next();
1914
+ let newCol = this.state.cursorCol;
1878
1915
 
1879
1916
  // Skip leading whitespace
1880
1917
  while (!next.done && isWhitespaceChar(next.value.segment)) {
1881
- this.state.cursorCol += next.value.segment.length;
1918
+ newCol += next.value.segment.length;
1882
1919
  next = iterator.next();
1883
1920
  }
1884
1921
 
@@ -1887,17 +1924,19 @@ export class Editor implements Component, Focusable {
1887
1924
  if (isPunctuationChar(firstGrapheme)) {
1888
1925
  // Skip punctuation run
1889
1926
  while (!next.done && isPunctuationChar(next.value.segment)) {
1890
- this.state.cursorCol += next.value.segment.length;
1927
+ newCol += next.value.segment.length;
1891
1928
  next = iterator.next();
1892
1929
  }
1893
1930
  } else {
1894
1931
  // Skip word run
1895
1932
  while (!next.done && !isWhitespaceChar(next.value.segment) && !isPunctuationChar(next.value.segment)) {
1896
- this.state.cursorCol += next.value.segment.length;
1933
+ newCol += next.value.segment.length;
1897
1934
  next = iterator.next();
1898
1935
  }
1899
1936
  }
1900
1937
  }
1938
+
1939
+ this.setCursorCol(newCol);
1901
1940
  }
1902
1941
 
1903
1942
  // Helper method to check if cursor is at start of message (for slash command detection)
@@ -2000,7 +2039,7 @@ https://github.com/EsotericSoftware/spine-runtimes/actions/runs/19536643416/job/
2000
2039
 
2001
2040
  this.state.lines = result.lines;
2002
2041
  this.state.cursorLine = result.cursorLine;
2003
- this.state.cursorCol = result.cursorCol;
2042
+ this.setCursorCol(result.cursorCol);
2004
2043
 
2005
2044
  if (this.onChange) {
2006
2045
  this.onChange(this.getText());