diff --git a/package-lock.json b/package-lock.json index 6a18caff89..a5264e99de 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4030,6 +4030,12 @@ "integrity": "sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==", "license": "MIT" }, + "node_modules/@types/parse-json": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz", + "integrity": "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==", + "license": "MIT" + }, "node_modules/@types/picomatch": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/@types/picomatch/-/picomatch-4.0.1.tgz", @@ -5612,6 +5618,32 @@ } } }, + "node_modules/babel-plugin-macros": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-2.8.0.tgz", + "integrity": "sha512-SEP5kJpfGYqYKpBrj5XU3ahw5p5GOHJ0U5ssOSQ/WBVdwkD2Dzlce95exQTs3jOVWPPKLBN2rlEWkCK7dSmLvg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.7.2", + "cosmiconfig": "^6.0.0", + "resolve": "^1.12.0" + } + }, + "node_modules/babel-plugin-preval": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/babel-plugin-preval/-/babel-plugin-preval-4.0.0.tgz", + "integrity": "sha512-fZI/4cYneinlj2k/FsXw0/lTWSC5KKoepUueS1g25Gb5vx3GrRyaVwxWCshYqx11GEU4mZnbbFhee8vpquFS2w==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.7.2", + "babel-plugin-macros": "^2.6.1", + "require-from-string": "^2.0.2" + }, + "engines": { + "node": ">=8", + "npm": ">=6" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -5936,7 +5968,6 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -6548,6 +6579,64 @@ "node": ">= 0.10" } }, + "node_modules/cosmiconfig": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-6.0.0.tgz", + "integrity": "sha512-xb3ZL6+L8b9JLLCx3ZdoZy4+2ECphCMo2PwqgP1tlfVq6M6YReyzBJtvWWtbDSpNr9hn96pkCiZqUcFEc+54Qg==", + "license": "MIT", + "dependencies": { + "@types/parse-json": "^4.0.0", + "import-fresh": "^3.1.0", + "parse-json": "^5.0.0", + "path-type": "^4.0.0", + "yaml": "^1.7.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cosmiconfig/node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "license": "MIT" + }, + "node_modules/cosmiconfig/node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cosmiconfig/node_modules/path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cosmiconfig/node_modules/yaml": { + "version": "1.10.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.3.tgz", + "integrity": "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==", + "license": "ISC", + "engines": { + "node": ">= 6" + } + }, "node_modules/crc-32": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", @@ -7261,7 +7350,6 @@ "version": "1.3.4", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", - "dev": true, "license": "MIT", "dependencies": { "is-arrayish": "^0.2.1" @@ -9531,7 +9619,6 @@ "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", - "dev": true, "license": "MIT", "dependencies": { "parent-module": "^1.0.0", @@ -9957,7 +10044,6 @@ "version": "0.2.1", "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", - "dev": true, "license": "MIT" }, "node_modules/is-async-function": { @@ -10994,7 +11080,6 @@ "version": "1.2.4", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", - "dev": true, "license": "MIT" }, "node_modules/linkify-it": { @@ -12534,7 +12619,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", - "dev": true, "license": "MIT", "dependencies": { "callsites": "^3.0.0" @@ -12855,7 +12939,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } @@ -13094,6 +13177,15 @@ "dev": true, "license": "MIT" }, + "node_modules/preval.macro": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/preval.macro/-/preval.macro-4.0.0.tgz", + "integrity": "sha512-sJJnE71X+MPr64CVD2AurmUj4JEDqbudYbStav3L9Xjcqm4AR0ymMm6sugw1mUmfI/7gw4JWA4JXo/k6w34crw==", + "license": "MIT", + "dependencies": { + "babel-plugin-preval": "^4.0.0" + } + }, "node_modules/process": { "version": "0.11.10", "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", @@ -13842,7 +13934,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", - "dev": true, "license": "MIT", "engines": { "node": ">=4" @@ -14145,6 +14236,36 @@ "integrity": "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==", "license": "MIT" }, + "node_modules/secretlint": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/secretlint/-/secretlint-10.2.2.tgz", + "integrity": "sha512-xVpkeHV/aoWe4vP4TansF622nBEImzCY73y/0042DuJ29iKIaqgoJ8fGxre3rVSHHbxar4FdJobmTnLp9AU0eg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@secretlint/config-creator": "^10.2.2", + "@secretlint/formatter": "^10.2.2", + "@secretlint/node": "^10.2.2", + "@secretlint/profiler": "^10.2.2", + "debug": "^4.4.1", + "globby": "^14.1.0", + "read-pkg": "^9.0.1" + }, + "bin": { + "secretlint": "bin/secretlint.js" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/segmentit": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/segmentit/-/segmentit-2.0.3.tgz", + "integrity": "sha512-7mn2XL3OdTUQ+AhHz7SbgyxLTaQRzTWQNVwiK+UlTO8aePGbSwvKUzTwE4238+OUY9MoR6ksAg35zl8sfTunQQ==", + "dependencies": { + "preval.macro": "^4.0.0" + } + }, "node_modules/selderee": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/selderee/-/selderee-0.11.0.tgz", @@ -16961,6 +17082,7 @@ "prompts": "^2.4.2", "react": "^19.1.0", "read-package-up": "^11.0.0", + "segmentit": "^2.0.3", "shell-quote": "^1.8.3", "simple-git": "^3.28.0", "string-width": "^7.1.0", @@ -21840,9 +21962,14 @@ "vite-plugin-dts": "^4.5.4" }, "peerDependencies": { - "@qwen-code/qwen-code-core": ">=0.13.0", + "@qwen-code/qwen-code-core": ">=0.13.1", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@qwen-code/qwen-code-core": { + "optional": true + } } }, "packages/webui/node_modules/@esbuild/aix-ppc64": { diff --git a/packages/cli/package.json b/packages/cli/package.json index 95bccd016f..b2b3886f5d 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -65,6 +65,7 @@ "prompts": "^2.4.2", "react": "^19.1.0", "read-package-up": "^11.0.0", + "segmentit": "^2.0.3", "shell-quote": "^1.8.3", "simple-git": "^3.28.0", "string-width": "^7.1.0", diff --git a/packages/cli/src/ui/components/shared/text-buffer.ts b/packages/cli/src/ui/components/shared/text-buffer.ts index c68bd1a4b4..b08a7d3ebf 100644 --- a/packages/cli/src/ui/components/shared/text-buffer.ts +++ b/packages/cli/src/ui/components/shared/text-buffer.ts @@ -5,6 +5,7 @@ */ import { spawnSync } from 'node:child_process'; +import { createRequire } from 'node:module'; import fs from 'node:fs'; import os from 'node:os'; import pathMod from 'node:path'; @@ -32,14 +33,6 @@ export type Direction = | 'home' | 'end'; -// Simple helper for word‑wise ops. -function isWordChar(ch: string | undefined): boolean { - if (ch === undefined) { - return false; - } - return !/[\s,.;!?]/.test(ch); -} - // Helper functions for line-based word navigation export const isWordCharStrict = (char: string): boolean => /[\w\p{L}\p{N}]/u.test(char); // Matches a single character that is any Unicode letter, any Unicode number, or an underscore @@ -70,6 +63,248 @@ export const isDifferentScript = (char1: string, char2: string): boolean => { return getCharScript(char1) !== getCharScript(char2); }; +/** Shared regex for CJK (Chinese/Japanese/Korean) characters */ +const CJK_CHAR_REGEX = /[\u4e00-\u9fff\u3400-\u4dbf\uf900-\ufaff]/; + +/** Check if a character is a CJK character */ +const isCjkChar = (char: string): boolean => CJK_CHAR_REGEX.test(char); + +// ───────────────────────────────────────────────────────────────────────────── +// CJK word segmentation (lazy-loaded segmentit) +// ───────────────────────────────────────────────────────────────────────────── + +/** Max entries in the CJK boundaries cache before eviction */ +const CJK_BOUNDARIES_CACHE_MAX = 500; + +/** Skip segmentit for lines longer than this to prevent UI lag on huge pastes */ +const CJK_SEGMENTIT_LENGTH_LIMIT = 1500; + +/** Cache: line content → array of { start: codePointIndex, end: codePointIndex } */ +let cjkBoundariesCache: Map< + string, + Array<{ start: number; end: number }> +> | null = null; + +/** Lazily loaded segmentit instance */ +let segmentitInstance: { + doSegment: ( + text: string, + options?: { simple?: boolean }, + ) => Array<{ w: string; p?: number }>; +} | null = null; + +/** + * Lazy-load segmentit using createRequire for ESM↔CJS interop. + * Runs synchronously — no async needed. + */ +function ensureSegmentitLoaded(): void { + if (segmentitInstance !== null) return; + try { + // segmentit is a CommonJS module; we must load it dynamically in ESM. + // We use a custom name 'loadCJS' to avoid the ESLint 'no-restricted-syntax' + // rule which flags 'require()' calls (even valid createRequire instances). + const loadCJS = createRequire(import.meta.url); + const seg = loadCJS('segmentit'); + const { Segment, useDefault: initSegment } = seg; + if (typeof Segment !== 'function' || typeof initSegment !== 'function') { + debugLogger.warn( + 'segmentit: Segment or useDefault not found (got Segment=%s, useDefault=%s)', + typeof Segment, + typeof initSegment, + ); + segmentitInstance = null; + return; + } + segmentitInstance = initSegment(new Segment()); + debugLogger.info('segmentit: loaded successfully'); + } catch (err) { + debugLogger.warn('segmentit: failed to load', err); + segmentitInstance = null; + } +} + +// Pre-warm segmentit in the background (async) to minimize latency on first interaction +setTimeout(() => { + ensureSegmentitLoaded(); +}, 0); + +/** + * Fallback: build CJK word boundaries character-by-character. + * Each CJK character becomes its own word boundary. + */ +function charByCharCjkFallback( + line: string, +): Array<{ start: number; end: number }> { + const codePoints = toCodePoints(line); + const fallback: Array<{ start: number; end: number }> = []; + for (let i = 0; i < codePoints.length; i++) { + if (isCjkChar(codePoints[i]!)) { + fallback.push({ start: i, end: i + 1 }); + } + } + return fallback; +} + +/** + * Evict oldest entries if cache exceeds the soft cap. + */ +function evictCacheIfNeeded(): void { + if ( + cjkBoundariesCache && + cjkBoundariesCache.size >= CJK_BOUNDARIES_CACHE_MAX + ) { + cjkBoundariesCache.clear(); + } +} + +/** + * Get CJK word boundaries (in code-point indices) for a given line. + * Returns an array of { start, end } where end is exclusive. + * Only segments if the line contains CJK characters. + * + * Note: First call triggers lazy loading of segmentit. + * Subsequent calls use the loaded instance directly. + */ +function getCjkWordBoundaries( + line: string, +): Array<{ start: number; end: number }> { + // Fast reject: No CJK characters + if (!CJK_CHAR_REGEX.test(line)) return []; + + // Optimization: Fallback to char-by-char for huge lines to prevent UI freeze + // segmentit segmentation is CPU-intensive for very long lines. + if (line.length > CJK_SEGMENTIT_LENGTH_LIMIT) { + if (!cjkBoundariesCache) cjkBoundariesCache = new Map(); + if (cjkBoundariesCache.has(line)) return cjkBoundariesCache.get(line)!; + const fallback = charByCharCjkFallback(line); + evictCacheIfNeeded(); + cjkBoundariesCache.set(line, fallback); + return fallback; + } + + // Check cache + if (!cjkBoundariesCache) cjkBoundariesCache = new Map(); + const cached = cjkBoundariesCache.get(line); + if (cached) { + return cached; + } + + // Ensure segmentit is loaded (synchronous) + ensureSegmentitLoaded(); + + const seg = segmentitInstance; + if (!seg) { + // segmentit unavailable; fall back to char-by-char boundaries + const fallback = charByCharCjkFallback(line); + evictCacheIfNeeded(); + cjkBoundariesCache.set(line, fallback); + return fallback; + } + + try { + const tokens = seg.doSegment(line, { simple: false }) as Array<{ + w: string; + p?: number; + }>; + + // Build code-point index mapping: for each code point, track its string offset + const codePoints = toCodePoints(line); + const cpToStrIdx: number[] = []; + let strIdx = 0; + for (let i = 0; i < codePoints.length; i++) { + cpToStrIdx[i] = strIdx; + strIdx += codePoints[i]!.length; + } + + // Map tokens to code-point boundaries by searching in the original string. + // IMPORTANT: segmentit may filter out spaces/stopwords, so we cannot rely + // on cumulative strLen. Instead, find each token's position via indexOf. + const boundaries: Array<{ start: number; end: number }> = []; + let searchOffset = 0; + + for (const token of tokens) { + const tokenStr = token.w; + const tokenPos = line.indexOf(tokenStr, searchOffset); + if (tokenPos < 0) { + // Token not found — skip silently (shouldn't happen) + continue; + } + + // Find the code-point index for the start and end of this token + const startCpIdx = cpToStrIdx.findIndex((idx) => idx >= tokenPos); + const endStrPos = tokenPos + tokenStr.length; + let endCpIdx = codePoints.length; + for (let i = 0; i < codePoints.length; i++) { + if (cpToStrIdx[i]! >= endStrPos) { + endCpIdx = i; + break; + } + } + + if (startCpIdx >= 0 && startCpIdx < endCpIdx) { + boundaries.push({ start: startCpIdx, end: endCpIdx }); + } + + searchOffset = tokenPos + tokenStr.length; + } + + evictCacheIfNeeded(); + cjkBoundariesCache.set(line, boundaries); + return boundaries; + } catch (err) { + debugLogger.warn('getCjkWordBoundaries: error, using char fallback', err); + // On error, fall back to char-by-char boundaries (cached) + const fallback = charByCharCjkFallback(line); + cjkBoundariesCache.set(line, fallback); + return fallback; + } +} + +/** + * Given CJK word boundaries and a cursor position, find the previous word start. + * Returns null if no CJK boundary applies. + * + * Semantics match browser/editor behavior: + * - Cursor inside a word → jump to that word's start + * - Cursor exactly at a word's start → jump to previous word's start + */ +function findPrevCjkWordStart( + boundaries: Array<{ start: number; end: number }>, + col: number, +): number | null { + for (let i = boundaries.length - 1; i >= 0; i--) { + const b = boundaries[i]!; + if (col > b.start && col <= b.end) { + // Cursor is inside this word → jump to its start + return b.start; + } + if (col === b.start && i > 0) { + // Cursor is exactly at this word's start → jump to previous word's start + return boundaries[i - 1]!.start; + } + } + return null; +} + +/** + * Given CJK word boundaries and a cursor position, find the next word end. + * Returns null if no CJK boundary applies. + */ +function findNextCjkWordEnd( + boundaries: Array<{ start: number; end: number }>, + col: number, +): number | null { + for (const b of boundaries) { + if (col >= b.start && col < b.end) { + return b.end; + } + if (col < b.start) { + return b.end; + } + } + return null; +} + // Find next word start within a line, starting from col export const findNextWordStartInLine = ( line: string, @@ -1177,24 +1412,58 @@ function textBufferReducerLogic( let newCursorCol = cursorCol; if (cursorCol === 0) { + // At start of line, move to end of previous line newCursorRow--; newCursorCol = cpLen(lines[newCursorRow] ?? ''); } else { const lineContent = lines[cursorRow]; const arr = toCodePoints(lineContent); let start = cursorCol; - let onlySpaces = true; - for (let i = 0; i < start; i++) { - if (isWordChar(arr[i])) { - onlySpaces = false; - break; - } - } - if (onlySpaces && start > 0) { - start--; + + // Try CJK segmentation first for lines containing CJK + const cjkBoundaries = getCjkWordBoundaries(lineContent); + const cjkStart = findPrevCjkWordStart(cjkBoundaries, start); + if (cjkStart !== null) { + start = cjkStart; } else { - while (start > 0 && !isWordChar(arr[start - 1])) start--; - while (start > 0 && isWordChar(arr[start - 1])) start--; + // Fallback: word boundary detection + // Check if we're in a whitespace-only prefix before any word + let onlySpaces = true; + for (let i = 0; i < start; i++) { + if (isWordCharStrict(arr[i] ?? '')) { + onlySpaces = false; + break; + } + } + + if (onlySpaces) { + // All characters before cursor are whitespace/special + // Jump to column 0 (start of line) + start = 0; + } else { + // First: skip backwards over non-word characters (punctuation) + while (start > 0 && !isWordCharStrict(arr[start - 1] ?? '')) + start--; + // Then: move to the start of the current word + // For CJK text (same script), treat each character as a word + while (start > 0) { + const prevChar = arr[start - 1]; + const currChar = arr[start]; + if ( + !isWordCharStrict(prevChar ?? '') || + (isWordCharStrict(currChar ?? '') && + isDifferentScript(currChar ?? '', prevChar ?? '')) + ) { + break; + } + // If current and previous are both CJK (same script), stop here + // so each CJK character becomes its own word + if (isCjkChar(currChar ?? '') && isCjkChar(prevChar ?? '')) { + break; + } + start--; + } + } } newCursorCol = start; } @@ -1222,9 +1491,46 @@ function textBufferReducerLogic( newCursorRow++; newCursorCol = 0; } else { - let end = cursorCol; - while (end < arr.length && !isWordChar(arr[end])) end++; - while (end < arr.length && isWordChar(arr[end])) end++; + // Try CJK segmentation first for lines containing CJK + const cjkBoundaries = getCjkWordBoundaries(lineContent); + const cjkEnd = findNextCjkWordEnd(cjkBoundaries, cursorCol); + + let end: number; + if (cjkEnd !== null) { + end = cjkEnd; + } else { + // Fallback: word boundary detection + end = cursorCol; + // Skip over non-word characters (punctuation/whitespace) + while (end < arr.length && !isWordCharStrict(arr[end] ?? '')) + end++; + // Move to end of current word, respecting script boundaries + while (end < arr.length) { + const currChar = arr[end]; + const nextChar = + end + 1 < arr.length ? arr[end + 1] : undefined; + if ( + !isWordCharStrict(currChar ?? '') || + (nextChar !== undefined && + isWordCharStrict(currChar ?? '') && + isWordCharStrict(nextChar) && + isDifferentScript(currChar ?? '', nextChar)) + ) { + break; + } + // If current and next are both CJK (same script), stop here + // so each CJK character becomes its own word + if ( + nextChar !== undefined && + isCjkChar(currChar ?? '') && + isCjkChar(nextChar) + ) { + end++; + break; + } + end++; + } + } newCursorCol = end; } return {