@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/dist/types/components/markdown.d.ts +9 -0
- package/dist/types/components/sayknow-pet.d.ts +128 -0
- package/dist/types/components/text.d.ts +9 -0
- package/dist/types/index.d.ts +1 -0
- package/dist/types/terminal-capabilities.d.ts +44 -0
- package/dist/types/tui.d.ts +55 -1
- package/dist/types/utils.d.ts +23 -0
- package/package.json +3 -3
- package/src/components/image.ts +23 -4
- package/src/components/markdown.ts +216 -29
- package/src/components/sayknow-pet.ts +435 -0
- package/src/components/text.ts +60 -36
- package/src/index.ts +1 -0
- package/src/terminal-capabilities.ts +119 -4
- package/src/tui.ts +503 -67
- package/src/utils.ts +144 -8
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
|
-
|
|
195
|
-
return
|
|
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
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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
|
-
|
|
216
|
-
return
|
|
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]/;
|