@sayknow-cli/tui 0.3.12 → 0.3.15

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/src/utils.ts CHANGED
@@ -5,6 +5,7 @@ import {
5
5
  sliceWithWidth as nativeSliceWithWidth,
6
6
  truncateLinesToWidth as nativeTruncateLinesToWidth,
7
7
  truncateToWidth as nativeTruncateToWidth,
8
+ visibleWidth as nativeVisibleWidth,
8
9
  visibleWidths as nativeVisibleWidths,
9
10
  wrapTextWithAnsi as nativeWrapTextWithAnsi,
10
11
  type SliceResult,
@@ -163,10 +164,142 @@ const segmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
163
164
  export function getSegmenter(): Intl.Segmenter {
164
165
  return segmenter;
165
166
  }
167
+
168
+ export interface ViewportAnchorSpan {
169
+ graphemeStart: number;
170
+ graphemeEnd: number;
171
+ cellStart: number;
172
+ cellEnd: number;
173
+ }
174
+
175
+ export interface ViewportAnchorAnnotation {
176
+ text: string;
177
+ nextGrapheme: number;
178
+ nextCell: number;
179
+ token: string;
180
+ }
181
+
182
+ // APC marker for viewport anchors. Must NOT start with "\x1b_G": that is the
183
+ // Kitty graphics prefix and TERMINAL.isImageLine() would misclassify every
184
+ // annotated line as an image line, which skips wrapping (issue: assistant
185
+ // prose overflowing the terminal on Kitty-protocol terminals).
186
+ export const VIEWPORT_ANCHOR_PREFIX = "\x1b_ASKC_ANCHOR:";
187
+ const VIEWPORT_ANCHOR_SUFFIX = "\x1b\\";
188
+
189
+ function ansiSequenceEnd(text: string, start: number): number {
190
+ if (text[start] !== "\x1b" || start + 1 >= text.length) return start;
191
+ const kind = text[start + 1];
192
+ if (kind === "[") {
193
+ let index = start + 2;
194
+ while (index < text.length) {
195
+ const code = text.charCodeAt(index++);
196
+ if (code >= 0x40 && code <= 0x7e) return index;
197
+ }
198
+ return text.length;
199
+ }
200
+ if (kind === "]" || kind === "_" || kind === "P" || kind === "^" || kind === "X") {
201
+ const bel = text.indexOf("\x07", start + 2);
202
+ const st = text.indexOf("\x1b\\", start + 2);
203
+ if (bel < 0) return st < 0 ? text.length : st + 2;
204
+ if (st < 0) return bel + 1;
205
+ return Math.min(bel + 1, st + 2);
206
+ }
207
+ return Math.min(text.length, start + 2);
208
+ }
209
+
210
+ /**
211
+ * Tag every visible grapheme with an APC marker that survives ANSI-aware
212
+ * wrapping. The marker contains source grapheme and monotonic cell offsets.
213
+ */
214
+ export function annotateViewportAnchorGraphemes(
215
+ text: string,
216
+ startGrapheme = 0,
217
+ startCell = 0,
218
+ token = crypto.randomUUID(),
219
+ ): ViewportAnchorAnnotation {
220
+ let result = "";
221
+ let grapheme = startGrapheme;
222
+ let cell = startCell;
223
+ let textStart = 0;
224
+ const appendVisible = (visible: string): void => {
225
+ for (const part of segmenter.segment(visible)) {
226
+ if (part.segment === "\n" || part.segment === "\r") {
227
+ result += part.segment;
228
+ continue;
229
+ }
230
+ const cellEnd = cell + Math.max(1, visibleWidth(part.segment));
231
+ result += `${part.segment}${VIEWPORT_ANCHOR_PREFIX}${token}:${grapheme}:${grapheme + 1}:${cell}:${cellEnd}${VIEWPORT_ANCHOR_SUFFIX}`;
232
+ grapheme += 1;
233
+ cell = cellEnd;
234
+ }
235
+ };
236
+ for (let index = 0; index < text.length; ) {
237
+ if (text[index] !== "\x1b") {
238
+ index += 1;
239
+ continue;
240
+ }
241
+ appendVisible(text.slice(textStart, index));
242
+ const end = ansiSequenceEnd(text, index);
243
+ result += text.slice(index, end);
244
+ index = end;
245
+ textStart = end;
246
+ }
247
+ appendVisible(text.slice(textStart));
248
+ return { text: result, nextGrapheme: grapheme, nextCell: cell, token };
249
+ }
250
+
251
+ /** Remove viewport anchor markers and return the exact marked span for each row. */
252
+ export function extractViewportAnchorRows(
253
+ lines: readonly string[],
254
+ token: string,
255
+ ): { lines: string[]; spans: Array<ViewportAnchorSpan | null> } {
256
+ const markerRegex = new RegExp(`\\x1b_ASKC_ANCHOR:${token}:(\\d+):(\\d+):(\\d+):(\\d+)\\x1b\\\\`, "g");
257
+ const cleanLines: string[] = [];
258
+ const spans: Array<ViewportAnchorSpan | null> = [];
259
+ for (const line of lines) {
260
+ let span: ViewportAnchorSpan | null = null;
261
+ const clean = line.replace(markerRegex, (_marker, start, end, cellStart, cellEnd) => {
262
+ const candidate = {
263
+ graphemeStart: Number(start),
264
+ graphemeEnd: Number(end),
265
+ cellStart: Number(cellStart),
266
+ cellEnd: Number(cellEnd),
267
+ };
268
+ if (!span) {
269
+ span = candidate;
270
+ } else {
271
+ span.graphemeStart = Math.min(span.graphemeStart, candidate.graphemeStart);
272
+ span.graphemeEnd = Math.max(span.graphemeEnd, candidate.graphemeEnd);
273
+ span.cellStart = Math.min(span.cellStart, candidate.cellStart);
274
+ span.cellEnd = Math.max(span.cellEnd, candidate.cellEnd);
275
+ }
276
+ return "";
277
+ });
278
+ cleanLines.push(clean);
279
+ spans.push(span);
280
+ }
281
+ return { lines: cleanLines, spans };
282
+ }
166
283
  function normalizeForWidth(str: string): string {
167
284
  const normalized = str.normalize("NFC");
168
285
  return normalized === str ? str : normalized;
169
286
  }
287
+
288
+ function hasUnpairedSurrogate(str: string): boolean {
289
+ for (let index = 0; index < str.length; index++) {
290
+ const code = str.charCodeAt(index);
291
+ if (code >= 0xd800 && code <= 0xdbff) {
292
+ const next = str.charCodeAt(index + 1);
293
+ if (next >= 0xdc00 && next <= 0xdfff) {
294
+ index += 1;
295
+ continue;
296
+ }
297
+ return true;
298
+ }
299
+ if (code >= 0xdc00 && code <= 0xdfff) return true;
300
+ }
301
+ return false;
302
+ }
170
303
  export function visibleWidthRaw(str: string): number {
171
304
  if (!str) {
172
305
  return 0;
@@ -191,8 +324,8 @@ export function visibleWidthRaw(str: string): number {
191
324
  return str.length + tabCount * (getCachedTabWidth() - 1);
192
325
  }
193
326
  const normalized = normalizeForWidth(str);
194
- if (tabCount === 0) return Bun.stringWidth(normalized);
195
- return Bun.stringWidth(normalized.replaceAll("\t", " ".repeat(getCachedTabWidth())));
327
+ const text = tabCount === 0 ? normalized : normalized.replaceAll("\t", " ".repeat(getCachedTabWidth()));
328
+ return nativeVisibleWidth(text, getCachedTabWidth());
196
329
  }
197
330
 
198
331
  /**
@@ -205,15 +338,18 @@ export function visibleWidth(str: string): number {
205
338
 
206
339
  export function visibleWidthsNative(lines: readonly string[]): number[] {
207
340
  __textHelperPerfCounters.visibleWidthsCalls += 1;
208
- return nativeVisibleWidths(
209
- lines.map(line => (typeof line === "string" ? line : String(line ?? ""))),
210
- getCachedTabWidth(),
211
- );
341
+ const safeLines = lines.map(line => (typeof line === "string" ? line : String(line ?? "")));
342
+ const widths = nativeVisibleWidths(safeLines, getCachedTabWidth());
343
+ for (let index = 0; index < safeLines.length; index++) {
344
+ const line = safeLines[index]!;
345
+ if (hasUnpairedSurrogate(line)) widths[index] = visibleWidthRaw(line);
346
+ }
347
+ return widths;
212
348
  }
213
349
 
214
350
  export function visibleWidths(lines: readonly string[]): number[] {
215
- void visibleWidthsNative(lines);
216
- return lines.map(line => visibleWidth(typeof line === "string" ? line : String(line ?? "")));
351
+ if (!renderMetrics.enabled) return visibleWidthsNative(lines);
352
+ return recordTextHelper("text.visibleWidths", () => visibleWidthsNative(lines));
217
353
  }
218
354
 
219
355
  const THAI_LAO_AM_REGEX = /[\u0e33\u0eb3]/;