omk-tui 0.90.6 → 0.90.7

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.
@@ -123,20 +123,26 @@ export function wordWrapLine(line, maxWidth, preSegmented) {
123
123
  wrapOppIndex = -1;
124
124
  }
125
125
  if (gWidth > maxWidth) {
126
- // Single atomic segment wider than maxWidth (e.g. paste marker
127
- // in a narrow terminal). Re-wrap it at grapheme granularity.
128
- // The segment remains logically atomic for cursor
129
- // movement / editing — the split is purely visual for word-wrap layout.
130
- const subChunks = wordWrapLine(grapheme, maxWidth);
131
- for (let j = 0; j < subChunks.length - 1; j++) {
132
- const sc = subChunks[j];
133
- chunks.push({ text: sc.text, startIndex: charIndex + sc.startIndex, endIndex: charIndex + sc.endIndex });
126
+ // Multi-grapheme atomic segment wider than maxWidth (e.g. paste marker
127
+ // in a narrow terminal): re-wrap it at grapheme granularity. The segment
128
+ // remains logically atomic for cursor movement / editing — the split is
129
+ // purely visual for word-wrap layout.
130
+ const subSegments = [...graphemeSegmenter.segment(grapheme)];
131
+ if (subSegments.length > 1) {
132
+ const subChunks = wordWrapLine(grapheme, maxWidth, subSegments);
133
+ for (let j = 0; j < subChunks.length - 1; j++) {
134
+ const sc = subChunks[j];
135
+ chunks.push({ text: sc.text, startIndex: charIndex + sc.startIndex, endIndex: charIndex + sc.endIndex });
136
+ }
137
+ const last = subChunks[subChunks.length - 1];
138
+ chunkStart = charIndex + last.startIndex;
139
+ currentWidth = visibleWidth(last.text);
140
+ wrapOppIndex = -1;
141
+ continue;
134
142
  }
135
- const last = subChunks[subChunks.length - 1];
136
- chunkStart = charIndex + last.startIndex;
137
- currentWidth = visibleWidth(last.text);
138
- wrapOppIndex = -1;
139
- continue;
143
+ // An indivisible grapheme wider than maxWidth (e.g. a wide CJK cell at
144
+ // width 1) cannot be split further: keep it whole and let the next
145
+ // overflow check force-break around it instead of recursing forever.
140
146
  }
141
147
  // Advance.
142
148
  currentWidth += gWidth;
@@ -889,8 +895,9 @@ export class Editor {
889
895
  }
890
896
  // Check if we should trigger or update autocomplete
891
897
  if (!this.autocompleteState) {
892
- // Auto-trigger for "/" at the start of a line (slash commands)
893
- if (char === "/" && this.isAtStartOfMessage()) {
898
+ // Auto-trigger for "/" at start of message (slash commands) or after whitespace
899
+ // mid-message (skill list). Absolute paths still fall through to path AC.
900
+ if (char === "/" && this.isSlashTokenTrigger()) {
894
901
  this.tryTriggerAutocomplete();
895
902
  }
896
903
  else if (char === "@" || char === "#") {
@@ -910,7 +917,8 @@ export class Editor {
910
917
  this.tryTriggerAutocomplete();
911
918
  }
912
919
  }
913
- // Also auto-trigger when typing letters in a slash command or symbol completion context
920
+ // Also auto-trigger when typing letters in a slash command, mid-message
921
+ // `/skill` token, bang launcher, bare first-token skill name, or @/# context
914
922
  else if (/[a-zA-Z0-9.\-_]/.test(char)) {
915
923
  const currentLine = this.state.lines[this.state.cursorLine] || "";
916
924
  const textBeforeCursor = currentLine.slice(0, this.state.cursorCol);
@@ -918,12 +926,18 @@ export class Editor {
918
926
  if (this.isInSlashCommandContext(textBeforeCursor)) {
919
927
  this.tryTriggerAutocomplete();
920
928
  }
929
+ else if (this.isMidMessageSlashSkillContext(textBeforeCursor)) {
930
+ this.tryTriggerAutocomplete();
931
+ }
921
932
  else if (textBeforeCursor.match(/(?:^|[\s])[@#][^\s]*$/)) {
922
933
  this.tryTriggerAutocomplete();
923
934
  }
924
935
  else if (this.isBangLauncherContext(textBeforeCursor)) {
925
936
  this.tryTriggerAutocomplete();
926
937
  }
938
+ else if (this.isPlainSkillLauncherContext(textBeforeCursor)) {
939
+ this.tryTriggerAutocomplete();
940
+ }
927
941
  }
928
942
  }
929
943
  else {
@@ -1075,12 +1089,18 @@ export class Editor {
1075
1089
  if (this.isInSlashCommandContext(textBeforeCursor)) {
1076
1090
  this.tryTriggerAutocomplete();
1077
1091
  }
1092
+ else if (this.isMidMessageSlashSkillContext(textBeforeCursor)) {
1093
+ this.tryTriggerAutocomplete();
1094
+ }
1078
1095
  else if (textBeforeCursor.match(/(?:^|[\s])[@#][^\s]*$/)) {
1079
1096
  this.tryTriggerAutocomplete();
1080
1097
  }
1081
1098
  else if (this.isBangLauncherContext(textBeforeCursor)) {
1082
1099
  this.tryTriggerAutocomplete();
1083
1100
  }
1101
+ else if (this.isPlainSkillLauncherContext(textBeforeCursor)) {
1102
+ this.tryTriggerAutocomplete();
1103
+ }
1084
1104
  }
1085
1105
  }
1086
1106
  /**
@@ -1092,6 +1112,45 @@ export class Editor {
1092
1112
  this.preferredVisualCol = null;
1093
1113
  this.snappedFromCursorCol = null;
1094
1114
  }
1115
+ /** Display-cell column of char offset `col` within the visual line starting at `startCol`. */
1116
+ displayColAt(lineText, startCol, col) {
1117
+ return visibleWidth(lineText.slice(startCol, col));
1118
+ }
1119
+ /** Char offset of the boundary at or before `displayCol` cells into `chunkText`. */
1120
+ offsetAtDisplayCol(chunkText, displayCol) {
1121
+ let cells = 0;
1122
+ let offset = 0;
1123
+ for (const seg of this.segment(chunkText, "grapheme")) {
1124
+ const segWidth = visibleWidth(seg.segment);
1125
+ if (cells + segWidth > displayCol) {
1126
+ // Landing inside this segment. Multi-char atomic segments (e.g.
1127
+ // paste markers) keep the char-level remainder so the snap logic
1128
+ // can record the pre-snap column; indivisible wide graphemes
1129
+ // floor to their start cell.
1130
+ if (seg.segment.length > 1) {
1131
+ offset += Math.min(seg.segment.length, Math.max(0, displayCol - cells));
1132
+ }
1133
+ break;
1134
+ }
1135
+ cells += segWidth;
1136
+ offset += seg.segment.length;
1137
+ }
1138
+ return offset;
1139
+ }
1140
+ /** Max reachable display-cell column on a visual line segment. */
1141
+ maxDisplayColOf(chunkText, isLastSegment) {
1142
+ if (isLastSegment)
1143
+ return visibleWidth(chunkText);
1144
+ // Non-final wrapped segments keep the cursor before the segment end:
1145
+ // clamp to the start cell of the last grapheme.
1146
+ let lastStart = 0;
1147
+ let cells = 0;
1148
+ for (const seg of this.segment(chunkText, "grapheme")) {
1149
+ lastStart = cells;
1150
+ cells += visibleWidth(seg.segment);
1151
+ }
1152
+ return Math.max(0, lastStart);
1153
+ }
1095
1154
  /**
1096
1155
  * Move cursor to a target visual line, applying sticky column logic.
1097
1156
  * Shared by moveCursor() and pageScroll().
@@ -1103,27 +1162,30 @@ export class Editor {
1103
1162
  return;
1104
1163
  // When the cursor was snapped to a segment start, resolve the pre-snap
1105
1164
  // position against the VL it belongs to. This gives the correct visual
1106
- // column even after a resize reshuffles VLs.
1165
+ // column even after a resize reshuffles VLs. Visual columns are display
1166
+ // cells, so wide (e.g. CJK) graphemes keep the cursor on the same column.
1167
+ const currentLineText = this.state.lines[currentVL.logicalLine] || "";
1107
1168
  let currentVisualCol;
1108
1169
  if (this.snappedFromCursorCol !== null) {
1109
1170
  const vlIndex = this.findVisualLineAt(visualLines, currentVL.logicalLine, this.snappedFromCursorCol);
1110
- currentVisualCol = this.snappedFromCursorCol - visualLines[vlIndex].startCol;
1171
+ currentVisualCol = this.displayColAt(currentLineText, visualLines[vlIndex].startCol, this.snappedFromCursorCol);
1111
1172
  }
1112
1173
  else {
1113
- currentVisualCol = this.state.cursorCol - currentVL.startCol;
1174
+ currentVisualCol = this.displayColAt(currentLineText, currentVL.startCol, this.state.cursorCol);
1114
1175
  }
1115
- // For non-last segments, clamp to length-1 to stay within the segment
1176
+ // For non-last segments, clamp to the last grapheme's start cell to stay within the segment
1116
1177
  const isLastSourceSegment = currentVisualLine === visualLines.length - 1 ||
1117
1178
  visualLines[currentVisualLine + 1]?.logicalLine !== currentVL.logicalLine;
1118
- const sourceMaxVisualCol = isLastSourceSegment ? currentVL.length : Math.max(0, currentVL.length - 1);
1179
+ const sourceMaxVisualCol = this.maxDisplayColOf(currentLineText.slice(currentVL.startCol, currentVL.startCol + currentVL.length), isLastSourceSegment);
1119
1180
  const isLastTargetSegment = targetVisualLine === visualLines.length - 1 ||
1120
1181
  visualLines[targetVisualLine + 1]?.logicalLine !== targetVL.logicalLine;
1121
- const targetMaxVisualCol = isLastTargetSegment ? targetVL.length : Math.max(0, targetVL.length - 1);
1182
+ const logicalLine = this.state.lines[targetVL.logicalLine] || "";
1183
+ const targetChunkText = logicalLine.slice(targetVL.startCol, targetVL.startCol + targetVL.length);
1184
+ const targetMaxVisualCol = this.maxDisplayColOf(targetChunkText, isLastTargetSegment);
1122
1185
  const moveToVisualCol = this.computeVerticalMoveColumn(currentVisualCol, sourceMaxVisualCol, targetMaxVisualCol);
1123
1186
  // Set cursor position
1124
1187
  this.state.cursorLine = targetVL.logicalLine;
1125
- const targetCol = targetVL.startCol + moveToVisualCol;
1126
- const logicalLine = this.state.lines[targetVL.logicalLine] || "";
1188
+ const targetCol = targetVL.startCol + this.offsetAtDisplayCol(targetChunkText, moveToVisualCol);
1127
1189
  this.state.cursorCol = Math.min(targetCol, logicalLine.length);
1128
1190
  // Snap cursor to atomic segment boundary (e.g. paste markers)
1129
1191
  // so the cursor never lands in the middle of a multi-grapheme unit.
@@ -1376,12 +1438,18 @@ export class Editor {
1376
1438
  if (this.isInSlashCommandContext(textBeforeCursor)) {
1377
1439
  this.tryTriggerAutocomplete();
1378
1440
  }
1441
+ else if (this.isMidMessageSlashSkillContext(textBeforeCursor)) {
1442
+ this.tryTriggerAutocomplete();
1443
+ }
1379
1444
  else if (textBeforeCursor.match(/(?:^|[\s])[@#][^\s]*$/)) {
1380
1445
  this.tryTriggerAutocomplete();
1381
1446
  }
1382
1447
  else if (this.isBangLauncherContext(textBeforeCursor)) {
1383
1448
  this.tryTriggerAutocomplete();
1384
1449
  }
1450
+ else if (this.isPlainSkillLauncherContext(textBeforeCursor)) {
1451
+ this.tryTriggerAutocomplete();
1452
+ }
1385
1453
  }
1386
1454
  }
1387
1455
  /**
@@ -1470,7 +1538,7 @@ export class Editor {
1470
1538
  // At end of last line - can't move, but set preferredVisualCol for up/down navigation
1471
1539
  const currentVL = visualLines[currentVisualLine];
1472
1540
  if (currentVL) {
1473
- this.preferredVisualCol = this.state.cursorCol - currentVL.startCol;
1541
+ this.preferredVisualCol = this.displayColAt(currentLine, currentVL.startCol, this.state.cursorCol);
1474
1542
  }
1475
1543
  }
1476
1544
  }
@@ -1693,9 +1761,29 @@ export class Editor {
1693
1761
  const beforeCursor = currentLine.slice(0, this.state.cursorCol);
1694
1762
  return beforeCursor.trim() === "" || beforeCursor.trim() === "/";
1695
1763
  }
1764
+ // "/" typed at start of message OR as a new token after whitespace (skill list mid-message).
1765
+ isSlashTokenTrigger() {
1766
+ if (!this.isSlashMenuAllowed())
1767
+ return false;
1768
+ if (this.isAtStartOfMessage())
1769
+ return true;
1770
+ const currentLine = this.state.lines[this.state.cursorLine] || "";
1771
+ const beforeCursor = currentLine.slice(0, this.state.cursorCol);
1772
+ // just typed "/" after whitespace
1773
+ return /(?:^|[\s])\/$/.test(beforeCursor);
1774
+ }
1696
1775
  isInSlashCommandContext(textBeforeCursor) {
1697
1776
  return this.isSlashMenuAllowed() && textBeforeCursor.trimStart().startsWith("/");
1698
1777
  }
1778
+ // Mid-message "/skill…" token (not an absolute path with extra slashes).
1779
+ // Provider rewrites selection to leading !skill: so submit can expand the skill.
1780
+ isMidMessageSlashSkillContext(textBeforeCursor) {
1781
+ if (!this.isSlashMenuAllowed())
1782
+ return false;
1783
+ if (textBeforeCursor.trimStart().startsWith("/"))
1784
+ return false;
1785
+ return /(?:^|[\s])\/[a-zA-Z0-9][a-zA-Z0-9:._-]*$/.test(textBeforeCursor);
1786
+ }
1699
1787
  // A "!" skill launcher is only actionable when it begins the whole message:
1700
1788
  // parseBangInvocation (coding-agent) only treats a message that starts with
1701
1789
  // "!" as a bang, so a mid-message "!skill:name" would be submitted as plain
@@ -1705,6 +1793,11 @@ export class Editor {
1705
1793
  isBangLauncherContext(textBeforeCursor) {
1706
1794
  return this.isSlashMenuAllowed() && /^!(?!!)[^\s]*$/.test(textBeforeCursor);
1707
1795
  }
1796
+ // Bare skill-name autocomplete at the start of the first line (no leading ! or /).
1797
+ // Selection inserts !skill:name so submit still goes through parseBangInvocation.
1798
+ isPlainSkillLauncherContext(textBeforeCursor) {
1799
+ return this.isSlashMenuAllowed() && /^[a-zA-Z0-9][a-zA-Z0-9.\-_]*$/.test(textBeforeCursor);
1800
+ }
1708
1801
  // Autocomplete methods
1709
1802
  /**
1710
1803
  * Find the best autocomplete item index for the given prefix.