@oh-my-pi/pi-tui 13.7.3 → 13.7.5

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 CHANGED
@@ -2,6 +2,11 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [13.7.5] - 2026-03-04
6
+ ### Changed
7
+
8
+ - Extracted word navigation logic into reusable `moveWordLeft` and `moveWordRight` utility functions for consistent cursor movement across components
9
+
5
10
  ## [13.6.2] - 2026-03-03
6
11
  ### Fixed
7
12
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-tui",
4
- "version": "13.7.3",
4
+ "version": "13.7.5",
5
5
  "description": "Terminal User Interface library with differential rendering for efficient text-based applications",
6
6
  "homepage": "https://github.com/can1357/oh-my-pi",
7
7
  "author": "Can Boluk",
@@ -33,8 +33,8 @@
33
33
  "test": "bun test test/*.test.ts"
34
34
  },
35
35
  "dependencies": {
36
- "@oh-my-pi/pi-natives": "13.7.3",
37
- "@oh-my-pi/pi-utils": "13.7.3",
36
+ "@oh-my-pi/pi-natives": "13.7.5",
37
+ "@oh-my-pi/pi-utils": "13.7.5",
38
38
  "marked": "^17.0"
39
39
  },
40
40
  "devDependencies": {
@@ -6,7 +6,15 @@ import { matchesKey } from "../keys";
6
6
  import { KillRing } from "../kill-ring";
7
7
  import type { SymbolTheme } from "../symbols";
8
8
  import { type Component, CURSOR_MARKER, type Focusable } from "../tui";
9
- import { getSegmenter, isPunctuationChar, isWhitespaceChar, padding, truncateToWidth, visibleWidth } from "../utils";
9
+ import {
10
+ getSegmenter,
11
+ getWordNavKind,
12
+ moveWordLeft,
13
+ moveWordRight,
14
+ padding,
15
+ truncateToWidth,
16
+ visibleWidth,
17
+ } from "../utils";
10
18
  import { SelectList, type SelectListTheme } from "./select-list";
11
19
 
12
20
  const segmenter = getSegmenter();
@@ -51,7 +59,7 @@ function wordWrapLine(line: string, maxWidth: number): TextChunk[] {
51
59
 
52
60
  for (const seg of segmenter.segment(line)) {
53
61
  const grapheme = seg.segment;
54
- const graphemeIsWhitespace = isWhitespaceChar(grapheme);
62
+ const graphemeIsWhitespace = getWordNavKind(grapheme) === "whitespace";
55
63
 
56
64
  if (currentToken === "") {
57
65
  inWhitespace = graphemeIsWhitespace;
@@ -89,6 +97,27 @@ function wordWrapLine(line: string, maxWidth: number): TextChunk[] {
89
97
  let chunkStartIndex = 0;
90
98
  let atLineStart = true; // Track if we're at the start of a line (for skipping whitespace)
91
99
 
100
+ function consumePrefixToWidth(text: string, availableWidth: number): { text: string; len: number } {
101
+ let prefix = "";
102
+ let prefixWidth = 0;
103
+ let len = 0;
104
+ for (const seg of segmenter.segment(text)) {
105
+ const grapheme = seg.segment;
106
+ const graphemeWidth = visibleWidth(grapheme);
107
+ if (prefixWidth + graphemeWidth > availableWidth) break;
108
+ prefix += grapheme;
109
+ prefixWidth += graphemeWidth;
110
+ len += grapheme.length;
111
+ if (prefixWidth === availableWidth) break;
112
+ }
113
+ return { text: prefix, len };
114
+ }
115
+ function hasWideGrapheme(text: string): boolean {
116
+ for (const seg of segmenter.segment(text)) {
117
+ if (visibleWidth(seg.segment) > 1) return true;
118
+ }
119
+ return false;
120
+ }
92
121
  for (const token of tokens) {
93
122
  const tokenWidth = visibleWidth(token.text);
94
123
 
@@ -101,28 +130,46 @@ function wordWrapLine(line: string, maxWidth: number): TextChunk[] {
101
130
 
102
131
  // If this single token is wider than maxWidth, we need to break it
103
132
  if (tokenWidth > maxWidth) {
104
- // First, push any accumulated chunk
133
+ // If we're mid-line, try to use the remaining width by consuming a prefix of this long token.
134
+ let consumedPrefix = "";
135
+ let consumedPrefixLen = 0; // JS string index (code units) consumed from token.text
136
+ if (currentChunk && currentWidth < maxWidth) {
137
+ const remainingWidth = maxWidth - currentWidth;
138
+ const consumed = consumePrefixToWidth(token.text, remainingWidth);
139
+ consumedPrefix = consumed.text;
140
+ consumedPrefixLen = consumed.len;
141
+ }
142
+ // First, push any accumulated chunk (optionally filled with the prefix).
105
143
  if (currentChunk) {
106
- chunks.push({
107
- text: currentChunk,
108
- startIndex: chunkStartIndex,
109
- endIndex: token.startIndex,
110
- });
111
- currentChunk = "";
112
- currentWidth = 0;
113
- chunkStartIndex = token.startIndex;
144
+ if (consumedPrefix) {
145
+ chunks.push({
146
+ text: currentChunk + consumedPrefix,
147
+ startIndex: chunkStartIndex,
148
+ endIndex: token.startIndex + consumedPrefixLen,
149
+ });
150
+ currentChunk = "";
151
+ currentWidth = 0;
152
+ chunkStartIndex = token.startIndex + consumedPrefixLen;
153
+ } else {
154
+ chunks.push({
155
+ text: currentChunk,
156
+ startIndex: chunkStartIndex,
157
+ endIndex: token.startIndex,
158
+ });
159
+ currentChunk = "";
160
+ currentWidth = 0;
161
+ chunkStartIndex = token.startIndex;
162
+ }
114
163
  }
115
-
116
- // Break the long token by grapheme
164
+ // Break the remaining long token by grapheme
165
+ const remainingText = consumedPrefixLen > 0 ? token.text.slice(consumedPrefixLen) : token.text;
117
166
  let tokenChunk = "";
118
167
  let tokenChunkWidth = 0;
119
- let tokenChunkStart = token.startIndex;
120
- let tokenCharIndex = token.startIndex;
121
-
122
- for (const seg of segmenter.segment(token.text)) {
168
+ let tokenChunkStart = token.startIndex + consumedPrefixLen;
169
+ let tokenCharIndex = token.startIndex + consumedPrefixLen;
170
+ for (const seg of segmenter.segment(remainingText)) {
123
171
  const grapheme = seg.segment;
124
172
  const graphemeWidth = visibleWidth(grapheme);
125
-
126
173
  if (tokenChunkWidth + graphemeWidth > maxWidth && tokenChunk) {
127
174
  chunks.push({
128
175
  text: tokenChunk,
@@ -138,7 +185,6 @@ function wordWrapLine(line: string, maxWidth: number): TextChunk[] {
138
185
  }
139
186
  tokenCharIndex += grapheme.length;
140
187
  }
141
-
142
188
  // Keep remainder as start of next chunk
143
189
  if (tokenChunk) {
144
190
  currentChunk = tokenChunk;
@@ -150,6 +196,25 @@ function wordWrapLine(line: string, maxWidth: number): TextChunk[] {
150
196
 
151
197
  // Check if adding this token would exceed width
152
198
  if (currentWidth + tokenWidth > maxWidth) {
199
+ // For wide-character tokens (e.g., CJK runs), prefer using remaining width before wrapping
200
+ // the whole token to the next line. This avoids leaving a short ASCII word alone.
201
+ if (currentChunk && !token.isWhitespace && currentWidth < maxWidth && hasWideGrapheme(token.text)) {
202
+ const remainingWidth = maxWidth - currentWidth;
203
+ const consumed = consumePrefixToWidth(token.text, remainingWidth);
204
+ if (consumed.text) {
205
+ chunks.push({
206
+ text: currentChunk + consumed.text,
207
+ startIndex: chunkStartIndex,
208
+ endIndex: token.startIndex + consumed.len,
209
+ });
210
+ const remainder = token.text.slice(consumed.len);
211
+ currentChunk = remainder;
212
+ currentWidth = visibleWidth(remainder);
213
+ chunkStartIndex = token.startIndex + consumed.len;
214
+ atLineStart = false;
215
+ continue;
216
+ }
217
+ }
153
218
  // Push current chunk (trimming trailing whitespace for display)
154
219
  const trimmedChunk = currentChunk.trimEnd();
155
220
  if (trimmedChunk || chunks.length === 0) {
@@ -159,7 +224,6 @@ function wordWrapLine(line: string, maxWidth: number): TextChunk[] {
159
224
  endIndex: chunkStartIndex + currentChunk.length,
160
225
  });
161
226
  }
162
-
163
227
  // Start new line - skip leading whitespace
164
228
  atLineStart = true;
165
229
  if (token.isWhitespace) {
@@ -1942,35 +2006,7 @@ export class Editor implements Component, Focusable {
1942
2006
  return;
1943
2007
  }
1944
2008
 
1945
- const textBeforeCursor = currentLine.slice(0, this.#state.cursorCol);
1946
- const graphemes = [...segmenter.segment(textBeforeCursor)];
1947
- let newCol = this.#state.cursorCol;
1948
-
1949
- // Skip trailing whitespace
1950
- while (graphemes.length > 0 && isWhitespaceChar(graphemes[graphemes.length - 1]?.segment || "")) {
1951
- newCol -= graphemes.pop()?.segment.length || 0;
1952
- }
1953
-
1954
- if (graphemes.length > 0) {
1955
- const lastGrapheme = graphemes[graphemes.length - 1]?.segment || "";
1956
- if (isPunctuationChar(lastGrapheme)) {
1957
- // Skip punctuation run
1958
- while (graphemes.length > 0 && isPunctuationChar(graphemes[graphemes.length - 1]?.segment || "")) {
1959
- newCol -= graphemes.pop()?.segment.length || 0;
1960
- }
1961
- } else {
1962
- // Skip word run
1963
- while (
1964
- graphemes.length > 0 &&
1965
- !isWhitespaceChar(graphemes[graphemes.length - 1]?.segment || "") &&
1966
- !isPunctuationChar(graphemes[graphemes.length - 1]?.segment || "")
1967
- ) {
1968
- newCol -= graphemes.pop()?.segment.length || 0;
1969
- }
1970
- }
1971
- }
1972
-
1973
- this.#setCursorCol(newCol);
2009
+ this.#setCursorCol(moveWordLeft(currentLine, this.#state.cursorCol));
1974
2010
  }
1975
2011
 
1976
2012
  /**
@@ -2019,36 +2055,7 @@ export class Editor implements Component, Focusable {
2019
2055
  return;
2020
2056
  }
2021
2057
 
2022
- const textAfterCursor = currentLine.slice(this.#state.cursorCol);
2023
- const segments = segmenter.segment(textAfterCursor);
2024
- const iterator = segments[Symbol.iterator]();
2025
- let next = iterator.next();
2026
- let newCol = this.#state.cursorCol;
2027
-
2028
- // Skip leading whitespace
2029
- while (!next.done && isWhitespaceChar(next.value.segment)) {
2030
- newCol += next.value.segment.length;
2031
- next = iterator.next();
2032
- }
2033
-
2034
- if (!next.done) {
2035
- const firstGrapheme = next.value.segment;
2036
- if (isPunctuationChar(firstGrapheme)) {
2037
- // Skip punctuation run
2038
- while (!next.done && isPunctuationChar(next.value.segment)) {
2039
- newCol += next.value.segment.length;
2040
- next = iterator.next();
2041
- }
2042
- } else {
2043
- // Skip word run
2044
- while (!next.done && !isWhitespaceChar(next.value.segment) && !isPunctuationChar(next.value.segment)) {
2045
- newCol += next.value.segment.length;
2046
- next = iterator.next();
2047
- }
2048
- }
2049
- }
2050
-
2051
- this.#setCursorCol(newCol);
2058
+ this.#setCursorCol(moveWordRight(currentLine, this.#state.cursorCol));
2052
2059
  }
2053
2060
 
2054
2061
  // Helper method to check if cursor is at start of message (for slash command detection)
@@ -2,7 +2,15 @@ import { BracketedPasteHandler } from "../bracketed-paste";
2
2
  import { getEditorKeybindings } from "../keybindings";
3
3
  import { KillRing } from "../kill-ring";
4
4
  import { type Component, CURSOR_MARKER, type Focusable } from "../tui";
5
- import { getSegmenter, isPunctuationChar, isWhitespaceChar, padding, visibleWidth } from "../utils";
5
+ import {
6
+ getSegmenter,
7
+ getWordNavKind,
8
+ moveWordLeft,
9
+ moveWordRight,
10
+ padding,
11
+ sliceWithWidth,
12
+ visibleWidth,
13
+ } from "../utils";
6
14
 
7
15
  const segmenter = getSegmenter();
8
16
 
@@ -173,7 +181,7 @@ export class Input implements Component, Focusable {
173
181
  }
174
182
 
175
183
  #insertCharacter(text: string): void {
176
- const isWordChunk = [...text].every(ch => !isWhitespaceChar(ch));
184
+ const isWordChunk = [...segmenter.segment(text)].every(seg => getWordNavKind(seg.segment) !== "whitespace");
177
185
  // Undo coalescing: consecutive word typing coalesces into one undo unit.
178
186
  if (!isWordChunk || this.#lastAction !== "type-word") {
179
187
  this.#pushUndo();
@@ -336,68 +344,15 @@ export class Input implements Component, Focusable {
336
344
  return;
337
345
  }
338
346
  this.#lastAction = null;
339
-
340
- const textBeforeCursor = this.#value.slice(0, this.#cursor);
341
- const graphemes = [...segmenter.segment(textBeforeCursor)];
342
-
343
- // Skip trailing whitespace
344
- while (graphemes.length > 0 && isWhitespaceChar(graphemes[graphemes.length - 1]?.segment || "")) {
345
- this.#cursor -= graphemes.pop()?.segment.length || 0;
346
- }
347
-
348
- if (graphemes.length > 0) {
349
- const lastGrapheme = graphemes[graphemes.length - 1]?.segment || "";
350
- if (isPunctuationChar(lastGrapheme)) {
351
- // Skip punctuation run
352
- while (graphemes.length > 0 && isPunctuationChar(graphemes[graphemes.length - 1]?.segment || "")) {
353
- this.#cursor -= graphemes.pop()?.segment.length || 0;
354
- }
355
- } else {
356
- // Skip word run
357
- while (
358
- graphemes.length > 0 &&
359
- !isWhitespaceChar(graphemes[graphemes.length - 1]?.segment || "") &&
360
- !isPunctuationChar(graphemes[graphemes.length - 1]?.segment || "")
361
- ) {
362
- this.#cursor -= graphemes.pop()?.segment.length || 0;
363
- }
364
- }
365
- }
347
+ this.#cursor = moveWordLeft(this.#value, this.#cursor);
366
348
  }
367
349
 
368
350
  #moveWordForwards(): void {
369
351
  if (this.#cursor >= this.#value.length) {
370
352
  return;
371
353
  }
372
-
373
354
  this.#lastAction = null;
374
- const textAfterCursor = this.#value.slice(this.#cursor);
375
- const segments = segmenter.segment(textAfterCursor);
376
- const iterator = segments[Symbol.iterator]();
377
- let next = iterator.next();
378
-
379
- // Skip leading whitespace
380
- while (!next.done && isWhitespaceChar(next.value.segment)) {
381
- this.#cursor += next.value.segment.length;
382
- next = iterator.next();
383
- }
384
-
385
- if (!next.done) {
386
- const firstGrapheme = next.value.segment;
387
- if (isPunctuationChar(firstGrapheme)) {
388
- // Skip punctuation run
389
- while (!next.done && isPunctuationChar(next.value.segment)) {
390
- this.#cursor += next.value.segment.length;
391
- next = iterator.next();
392
- }
393
- } else {
394
- // Skip word run
395
- while (!next.done && !isWhitespaceChar(next.value.segment) && !isPunctuationChar(next.value.segment)) {
396
- this.#cursor += next.value.segment.length;
397
- next = iterator.next();
398
- }
399
- }
400
- }
355
+ this.#cursor = moveWordRight(this.#value, this.#cursor);
401
356
  }
402
357
 
403
358
  #handlePaste(pastedText: string): void {
@@ -425,61 +380,37 @@ export class Input implements Component, Focusable {
425
380
  return [prompt];
426
381
  }
427
382
 
428
- let visibleText = "";
429
- let cursorDisplay = this.#cursor;
430
-
431
- if (this.#value.length < availableWidth) {
432
- // Everything fits (leave room for cursor at end)
433
- visibleText = this.#value;
434
- } else {
435
- // Need horizontal scrolling
436
- // Reserve one character for cursor if it's at the end
437
- const scrollWidth = this.#cursor === this.#value.length ? availableWidth - 1 : availableWidth;
438
- const halfWidth = Math.floor(scrollWidth / 2);
439
-
440
- const findValidStart = (start: number) => {
441
- while (start < this.#value.length) {
442
- const charCode = this.#value.charCodeAt(start);
443
- // this is low surrogate, not a valid start
444
- if (charCode >= 0xdc00 && charCode < 0xe000) {
445
- start++;
446
- continue;
447
- }
448
- break;
449
- }
450
- return start;
451
- };
452
-
453
- const findValidEnd = (end: number) => {
454
- while (end > 0) {
455
- const charCode = this.#value.charCodeAt(end - 1);
456
- // this is high surrogate, might be split.
457
- if (charCode >= 0xd800 && charCode < 0xdc00) {
458
- end--;
459
- continue;
460
- }
461
- break;
462
- }
463
- return end;
464
- };
465
-
466
- if (this.#cursor < halfWidth) {
467
- // Cursor near start
468
- visibleText = this.#value.slice(0, findValidEnd(scrollWidth));
469
- cursorDisplay = this.#cursor;
470
- } else if (this.#cursor > this.#value.length - halfWidth) {
471
- // Cursor near end
472
- const start = findValidStart(this.#value.length - scrollWidth);
473
- visibleText = this.#value.slice(start);
474
- cursorDisplay = this.#cursor - start;
475
- } else {
476
- // Cursor in middle
477
- const start = findValidStart(this.#cursor - halfWidth);
478
- visibleText = this.#value.slice(start, findValidEnd(start + scrollWidth));
479
- cursorDisplay = this.#cursor - start;
383
+ const cursorIndex = this.#cursor;
384
+ // Ensure we always have a grapheme to invert at the cursor (space at end).
385
+ const displayValue = cursorIndex >= this.#value.length ? `${this.#value} ` : this.#value;
386
+
387
+ const totalCols = visibleWidth(displayValue);
388
+ const cursorCols = visibleWidth(displayValue.slice(0, cursorIndex));
389
+
390
+ // Width of the grapheme at the cursor, for ensuring it fits in the viewport.
391
+ const cursorIter = segmenter.segment(displayValue.slice(cursorIndex))[Symbol.iterator]();
392
+ const cursorG = cursorIter.next().value?.segment ?? " ";
393
+ const cursorGWidth = visibleWidth(cursorG);
394
+
395
+ const maxStart = Math.max(0, totalCols - availableWidth);
396
+ let startCol = 0;
397
+ if (totalCols > availableWidth) {
398
+ const half = Math.floor(availableWidth / 2);
399
+ startCol = Math.max(0, Math.min(maxStart, cursorCols - half));
400
+
401
+ // Ensure the cursor grapheme is inside the viewport (and fits fully if wide).
402
+ const maxCursorRel = Math.max(0, availableWidth - cursorGWidth);
403
+ const cursorRel = cursorCols - startCol;
404
+ if (cursorRel > maxCursorRel) {
405
+ startCol = Math.max(0, Math.min(maxStart, cursorCols - maxCursorRel));
480
406
  }
481
407
  }
482
408
 
409
+ const visibleText = sliceWithWidth(displayValue, startCol, availableWidth, true).text;
410
+ const prefixText = sliceWithWidth(displayValue, startCol, Math.max(0, cursorCols - startCol), true).text;
411
+ let cursorDisplay = prefixText.length;
412
+ cursorDisplay = Math.max(0, Math.min(cursorDisplay, visibleText.length));
413
+
483
414
  // Build line with fake cursor
484
415
  // Insert cursor character at cursor position
485
416
  const graphemes = [...segmenter.segment(visibleText.slice(cursorDisplay))];
@@ -491,16 +422,20 @@ export class Input implements Component, Focusable {
491
422
 
492
423
  // Hardware cursor marker (zero-width, emitted before fake cursor for IME positioning)
493
424
  const marker = this.focused ? CURSOR_MARKER : "";
494
-
495
425
  // Use inverse video to show cursor
496
426
  const cursorChar = `\x1b[7m${atCursor}\x1b[27m`; // ESC[7m = reverse video, ESC[27m = normal
497
- const textWithCursor = beforeCursor + marker + cursorChar + afterCursor;
498
427
 
499
- // Calculate visual width
500
- const visualLength = visibleWidth(textWithCursor);
428
+ // Clamp only the trailing text (measured in terminal cells), keeping the cursor marker intact.
429
+ const beforeWidth = visibleWidth(beforeCursor);
430
+ const cursorWidth = visibleWidth(atCursor);
431
+ const remainingAfterWidth = Math.max(0, availableWidth - beforeWidth - cursorWidth);
432
+ const clampedAfterCursor = sliceWithWidth(afterCursor, 0, remainingAfterWidth, true).text;
433
+ const renderedNoMarker = beforeCursor + cursorChar + clampedAfterCursor;
434
+ const textWithCursor = beforeCursor + marker + cursorChar + clampedAfterCursor;
435
+
436
+ const visualLength = visibleWidth(renderedNoMarker);
501
437
  const pad = padding(Math.max(0, availableWidth - visualLength));
502
438
  const line = prompt + textWithCursor + pad;
503
-
504
439
  return [line];
505
440
  }
506
441
  }
package/src/utils.ts CHANGED
@@ -97,6 +97,167 @@ export function isPunctuationChar(char: string): boolean {
97
97
  return ASCII_PUNCTUATION[code] ?? false;
98
98
  }
99
99
 
100
+ export type WordNavKind = "whitespace" | "delimiter" | "cjk" | "word" | "other";
101
+
102
+ const WORD_NAV_RE_WHITESPACE = /^\p{White_Space}$/u;
103
+ const WORD_NAV_RE_PUNCT = /^\p{P}$/u;
104
+ const WORD_NAV_RE_SYMBOL = /^\p{S}$/u;
105
+ const WORD_NAV_RE_LETTER = /^\p{L}$/u;
106
+ const WORD_NAV_RE_NUMBER = /^\p{N}$/u;
107
+ const WORD_NAV_RE_HAN = /^\p{Script=Han}$/u;
108
+ const WORD_NAV_RE_HIRAGANA = /^\p{Script=Hiragana}$/u;
109
+ const WORD_NAV_RE_KATAKANA = /^\p{Script=Katakana}$/u;
110
+ const WORD_NAV_RE_HANGUL = /^\p{Script=Hangul}$/u;
111
+
112
+ function firstCodePointChar(str: string): string {
113
+ const cp = str.codePointAt(0);
114
+ if (cp === undefined) return "";
115
+ return String.fromCodePoint(cp);
116
+ }
117
+
118
+ /**
119
+ * Coarse Unicode-aware character classification for word navigation (Option/Alt + Left/Right).
120
+ * This intentionally avoids language-specific word segmentation for predictability across scripts.
121
+ */
122
+ export function getWordNavKind(grapheme: string): WordNavKind {
123
+ if (!grapheme) return "other";
124
+ const ch = firstCodePointChar(grapheme);
125
+ if (!ch) return "other";
126
+ if (WORD_NAV_RE_WHITESPACE.test(ch)) return "whitespace";
127
+ if (WORD_NAV_RE_PUNCT.test(ch) || WORD_NAV_RE_SYMBOL.test(ch)) return "delimiter";
128
+ if (
129
+ WORD_NAV_RE_HAN.test(ch) ||
130
+ WORD_NAV_RE_HIRAGANA.test(ch) ||
131
+ WORD_NAV_RE_KATAKANA.test(ch) ||
132
+ WORD_NAV_RE_HANGUL.test(ch)
133
+ ) {
134
+ return "cjk";
135
+ }
136
+ if (ch === "_" || WORD_NAV_RE_LETTER.test(ch) || WORD_NAV_RE_NUMBER.test(ch)) return "word";
137
+ return "other";
138
+ }
139
+
140
+ const WORD_NAV_JOINERS = new Set(["'", "’", "-", "‐", "‑"]);
141
+
142
+ export function isWordNavJoiner(grapheme: string): boolean {
143
+ const ch = firstCodePointChar(grapheme);
144
+ return WORD_NAV_JOINERS.has(ch);
145
+ }
146
+
147
+ /**
148
+ * Move the cursor one "word" to the left using Unicode-aware coarse navigation.
149
+ *
150
+ * Returns a new cursor index in the range [0, text.length].
151
+ */
152
+ export function moveWordLeft(text: string, cursor: number): number {
153
+ const len = text.length;
154
+ if (len === 0) return 0;
155
+ let i = Math.min(Math.max(cursor, 0), len);
156
+ if (i === 0) return 0;
157
+
158
+ const graphemes = [...segmenter.segment(text.slice(0, i))];
159
+ if (graphemes.length === 0) return 0;
160
+
161
+ // Skip trailing whitespace.
162
+ while (graphemes.length > 0 && getWordNavKind(graphemes[graphemes.length - 1]?.segment || "") === "whitespace") {
163
+ i -= graphemes.pop()?.segment.length || 0;
164
+ }
165
+ if (i === 0 || graphemes.length === 0) return i;
166
+
167
+ const kind = getWordNavKind(graphemes[graphemes.length - 1]?.segment || "");
168
+ if (kind === "delimiter" || kind === "cjk") {
169
+ while (graphemes.length > 0 && getWordNavKind(graphemes[graphemes.length - 1]?.segment || "") === kind) {
170
+ i -= graphemes.pop()?.segment.length || 0;
171
+ }
172
+ return i;
173
+ }
174
+
175
+ if (kind === "word") {
176
+ // Skip word run (letters/numbers/underscore), keeping common joiners inside words.
177
+ let hasRightWord = false;
178
+ while (graphemes.length > 0) {
179
+ const g = graphemes[graphemes.length - 1]?.segment || "";
180
+ const k = getWordNavKind(g);
181
+ if (k === "word") {
182
+ hasRightWord = true;
183
+ i -= graphemes.pop()?.segment.length || 0;
184
+ continue;
185
+ }
186
+ if (hasRightWord && k === "delimiter" && isWordNavJoiner(g)) {
187
+ const left = graphemes[graphemes.length - 2]?.segment || "";
188
+ if (getWordNavKind(left) === "word") {
189
+ i -= graphemes.pop()?.segment.length || 0;
190
+ continue;
191
+ }
192
+ }
193
+ break;
194
+ }
195
+ return i;
196
+ }
197
+
198
+ // Fallback: move by one grapheme.
199
+ i -= graphemes.pop()?.segment.length || 0;
200
+ return Math.max(0, i);
201
+ }
202
+
203
+ /**
204
+ * Move the cursor one "word" to the right using Unicode-aware coarse navigation.
205
+ *
206
+ * Returns a new cursor index in the range [0, text.length].
207
+ */
208
+ export function moveWordRight(text: string, cursor: number): number {
209
+ const len = text.length;
210
+ if (len === 0) return 0;
211
+ let i = Math.min(Math.max(cursor, 0), len);
212
+ if (i === len) return len;
213
+
214
+ const iterator = segmenter.segment(text.slice(i))[Symbol.iterator]();
215
+ let next = iterator.next();
216
+
217
+ // Skip leading whitespace.
218
+ while (!next.done && getWordNavKind(next.value.segment) === "whitespace") {
219
+ i += next.value.segment.length;
220
+ next = iterator.next();
221
+ }
222
+ if (next.done) return i;
223
+
224
+ const firstKind = getWordNavKind(next.value.segment);
225
+ if (firstKind === "delimiter" || firstKind === "cjk") {
226
+ while (!next.done && getWordNavKind(next.value.segment) === firstKind) {
227
+ i += next.value.segment.length;
228
+ next = iterator.next();
229
+ }
230
+ return i;
231
+ }
232
+
233
+ if (firstKind === "word") {
234
+ let hasLeftWord = false;
235
+ while (!next.done) {
236
+ const segment = next.value.segment;
237
+ const k = getWordNavKind(segment);
238
+ if (k === "word") {
239
+ hasLeftWord = true;
240
+ i += segment.length;
241
+ next = iterator.next();
242
+ continue;
243
+ }
244
+ if (hasLeftWord && k === "delimiter" && isWordNavJoiner(segment)) {
245
+ const lookahead = iterator.next();
246
+ if (!lookahead.done && getWordNavKind(lookahead.value.segment) === "word") {
247
+ i += segment.length;
248
+ next = lookahead;
249
+ continue;
250
+ }
251
+ }
252
+ break;
253
+ }
254
+ return i;
255
+ }
256
+
257
+ // Fallback: move by one grapheme.
258
+ return i + next.value.segment.length;
259
+ }
260
+
100
261
  /**
101
262
  * Apply background color to a line, padding to full width.
102
263
  *
@@ -117,6 +278,7 @@ export function applyBackgroundToLine(line: string, width: number, bgFn: (text:
117
278
 
118
279
  /**
119
280
  * Extract a range of visible columns from a line. Handles ANSI codes and wide chars.
281
+ *
120
282
  * @param strict - If true, exclude wide chars at boundary that would extend past the range
121
283
  */
122
284
  export function sliceByColumn(line: string, startCol: number, length: number, strict = false): string {