@gajae-code/tui 0.18.8 → 0.18.10
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 +19 -0
- package/dist/types/tui.d.ts +2 -0
- package/dist/types/utils.d.ts +70 -0
- package/package.json +3 -3
- package/src/components/markdown.ts +484 -62
- package/src/tui.ts +503 -72
- package/src/utils.ts +196 -0
package/src/utils.ts
CHANGED
|
@@ -193,12 +193,208 @@ export interface ViewportAnchorAnnotation {
|
|
|
193
193
|
token: string;
|
|
194
194
|
}
|
|
195
195
|
|
|
196
|
+
export interface CopyRowAnnotation {
|
|
197
|
+
token: number;
|
|
198
|
+
kind: string;
|
|
199
|
+
/** Actual logical source line corresponding to this row, never a token copy. */
|
|
200
|
+
source: string;
|
|
201
|
+
/**
|
|
202
|
+
* Present on the token's last row (also while streaming), for complete-token
|
|
203
|
+
* reconstruction. Only the growing tail token's last row changes per delta, and
|
|
204
|
+
* ending a stream leaves already-painted rows byte-identical.
|
|
205
|
+
*/
|
|
206
|
+
tokenSource?: string;
|
|
207
|
+
/**
|
|
208
|
+
* This row's position among its token's rendered rows. A container that shows
|
|
209
|
+
* only part of a rendered token (a clipped preview) keeps it, so a selection can
|
|
210
|
+
* tell it does not start at the token's first row.
|
|
211
|
+
*/
|
|
212
|
+
tokenRow: number;
|
|
213
|
+
/** The token's rendered row count; present with `tokenSource` on the last row only. */
|
|
214
|
+
tokenRows?: number;
|
|
215
|
+
/** `source` is this code row's original-spelling slice (tabs intact), at any nesting depth. */
|
|
216
|
+
originalCode?: true;
|
|
217
|
+
joinGap: string;
|
|
218
|
+
contentStart: number;
|
|
219
|
+
contentEnd: number;
|
|
220
|
+
continuation: boolean;
|
|
221
|
+
prefixColumns: number;
|
|
222
|
+
fence: boolean;
|
|
223
|
+
quoteDepth: number;
|
|
224
|
+
ranges?: Array<[number, number]>;
|
|
225
|
+
/**
|
|
226
|
+
* On a table row that continues the wrapped cells of the row above: per cell,
|
|
227
|
+
* the source text the soft wrap removed before this row's fragment.
|
|
228
|
+
*/
|
|
229
|
+
cellGaps?: string[];
|
|
230
|
+
}
|
|
231
|
+
|
|
196
232
|
// APC marker for viewport anchors. Must NOT start with "\x1b_G": that is the
|
|
197
233
|
// Kitty graphics prefix and TERMINAL.isImageLine() would misclassify every
|
|
198
234
|
// annotated line as an image line, which skips wrapping (issue: assistant
|
|
199
235
|
// prose overflowing the terminal on Kitty-protocol terminals).
|
|
200
236
|
export const VIEWPORT_ANCHOR_PREFIX = "\x1b_AGJC_ANCHOR:";
|
|
201
237
|
const VIEWPORT_ANCHOR_SUFFIX = "\x1b\\";
|
|
238
|
+
const COPY_ANNOTATION_PREFIX = "\x1b_AGJC_COPY:";
|
|
239
|
+
// Kept module-private so ordinary terminal text cannot forge semantic clipboard rows.
|
|
240
|
+
const copyAnnotationNonce = crypto.randomUUID();
|
|
241
|
+
const COPY_ANNOTATION_REGEX = /\x1b_AGJC_COPY:([^:]+):([^:]+):([^\x1b]*)\x1b\\/gu;
|
|
242
|
+
const COPY_ORIGIN_PREFIX = "\x1b_AGJC_COPY_ORIGIN:";
|
|
243
|
+
const COPY_ORIGIN_REGEX = /\x1b_AGJC_COPY_ORIGIN:([^\x1b]*)\x1b\\/gu;
|
|
244
|
+
/** Every in-process copy APC (row, origin, renderer hints); none may reach a terminal. */
|
|
245
|
+
const ANY_COPY_APC_MARKER = "\x1b_AGJC_COPY";
|
|
246
|
+
const ANY_COPY_APC_REGEX = /\x1b_AGJC_COPY[A-Z_]*:[^\x1b]*\x1b\\/gu;
|
|
247
|
+
|
|
248
|
+
let copyAnnotationRetainers = 0;
|
|
249
|
+
/**
|
|
250
|
+
* Copy annotations are emitted only while a TUI that copies mouse selections is
|
|
251
|
+
* running. The switch is process-wide: while it is on, every Markdown render in
|
|
252
|
+
* the process carries zero-width copy APCs, which TUI.#emit strips at the
|
|
253
|
+
* terminal write boundary. With no such TUI running, render() output is
|
|
254
|
+
* byte-identical to the stock renderer. Returns an idempotent release.
|
|
255
|
+
*/
|
|
256
|
+
export function retainCopyAnnotations(): () => void {
|
|
257
|
+
copyAnnotationRetainers++;
|
|
258
|
+
let released = false;
|
|
259
|
+
return () => {
|
|
260
|
+
if (released) return;
|
|
261
|
+
released = true;
|
|
262
|
+
copyAnnotationRetainers--;
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
export function copyAnnotationsEnabled(): boolean {
|
|
266
|
+
return copyAnnotationRetainers > 0;
|
|
267
|
+
}
|
|
268
|
+
/** Remove all copy APCs; applied to bytes at the terminal write boundary. */
|
|
269
|
+
export function stripCopyApcs(data: string): string {
|
|
270
|
+
return data.includes(ANY_COPY_APC_MARKER) ? data.replace(ANY_COPY_APC_REGEX, "") : data;
|
|
271
|
+
}
|
|
272
|
+
const COPY_ROW_KINDS = new Set([
|
|
273
|
+
"heading",
|
|
274
|
+
"paragraph",
|
|
275
|
+
"code",
|
|
276
|
+
"list",
|
|
277
|
+
"table",
|
|
278
|
+
"blockquote",
|
|
279
|
+
"hr",
|
|
280
|
+
"html",
|
|
281
|
+
"space",
|
|
282
|
+
"text",
|
|
283
|
+
"def",
|
|
284
|
+
]);
|
|
285
|
+
const isCopyColumn = (value: unknown): value is number =>
|
|
286
|
+
typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
|
|
287
|
+
|
|
288
|
+
/** Zero-width content origin; parent containers may prepend visible columns. */
|
|
289
|
+
export function annotateCopyOrigin(line: string): string {
|
|
290
|
+
return `${COPY_ORIGIN_PREFIX}${copyAnnotationNonce}${VIEWPORT_ANCHOR_SUFFIX}${line}`;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** Attach renderer-owned semantic copy data after a row's visible content. */
|
|
294
|
+
export function annotateCopyRow(line: string, sourceId: string, annotation: CopyRowAnnotation): string {
|
|
295
|
+
return `${line}${COPY_ANNOTATION_PREFIX}${copyAnnotationNonce}:${sourceId}:${encodeURIComponent(JSON.stringify(annotation))}${VIEWPORT_ANCHOR_SUFFIX}`;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** Read the annotation carried by this exact rendered row. */
|
|
299
|
+
export function extractCopyRowAnnotation(
|
|
300
|
+
line: string,
|
|
301
|
+
): { line: string; sourceId: string; annotation: CopyRowAnnotation; originColumn: number } | null {
|
|
302
|
+
// Assigned inside the replace callback; a holder object keeps TypeScript from narrowing it to null.
|
|
303
|
+
const found: { value: { sourceId: string; annotation: CopyRowAnnotation } | null } = { value: null };
|
|
304
|
+
let originColumn: number | undefined;
|
|
305
|
+
const withoutOrigin = line.replace(COPY_ORIGIN_REGEX, (_marker, nonce: string, offset: number) => {
|
|
306
|
+
if (nonce === copyAnnotationNonce && originColumn === undefined)
|
|
307
|
+
originColumn = visibleWidth(line.slice(0, offset));
|
|
308
|
+
return "";
|
|
309
|
+
});
|
|
310
|
+
const clean = withoutOrigin.replace(
|
|
311
|
+
COPY_ANNOTATION_REGEX,
|
|
312
|
+
(_marker, nonce: string, sourceId: string, encoded: string) => {
|
|
313
|
+
if (nonce !== copyAnnotationNonce || found.value !== null) return "";
|
|
314
|
+
try {
|
|
315
|
+
const value: unknown = JSON.parse(decodeURIComponent(encoded));
|
|
316
|
+
const annotation = value as Partial<CopyRowAnnotation>;
|
|
317
|
+
if (
|
|
318
|
+
typeof value !== "object" ||
|
|
319
|
+
value === null ||
|
|
320
|
+
!isCopyColumn(annotation.token) ||
|
|
321
|
+
typeof annotation.kind !== "string" ||
|
|
322
|
+
!COPY_ROW_KINDS.has(annotation.kind) ||
|
|
323
|
+
typeof annotation.source !== "string" ||
|
|
324
|
+
(annotation.tokenSource !== undefined && typeof annotation.tokenSource !== "string") ||
|
|
325
|
+
!isCopyColumn(annotation.tokenRow) ||
|
|
326
|
+
(annotation.tokenRows !== undefined &&
|
|
327
|
+
(!isCopyColumn(annotation.tokenRows) || annotation.tokenRow >= annotation.tokenRows)) ||
|
|
328
|
+
(annotation.originalCode !== undefined && annotation.originalCode !== true) ||
|
|
329
|
+
typeof annotation.joinGap !== "string" ||
|
|
330
|
+
!isCopyColumn(annotation.contentStart) ||
|
|
331
|
+
!isCopyColumn(annotation.contentEnd) ||
|
|
332
|
+
annotation.contentEnd < annotation.contentStart ||
|
|
333
|
+
typeof annotation.continuation !== "boolean" ||
|
|
334
|
+
!isCopyColumn(annotation.prefixColumns) ||
|
|
335
|
+
typeof annotation.fence !== "boolean" ||
|
|
336
|
+
!isCopyColumn(annotation.quoteDepth) ||
|
|
337
|
+
(annotation.ranges !== undefined &&
|
|
338
|
+
(!Array.isArray(annotation.ranges) ||
|
|
339
|
+
annotation.ranges.some(
|
|
340
|
+
range =>
|
|
341
|
+
!Array.isArray(range) ||
|
|
342
|
+
range.length !== 2 ||
|
|
343
|
+
!isCopyColumn(range[0]) ||
|
|
344
|
+
!isCopyColumn(range[1]) ||
|
|
345
|
+
range[1] < range[0],
|
|
346
|
+
))) ||
|
|
347
|
+
(annotation.cellGaps !== undefined &&
|
|
348
|
+
(!Array.isArray(annotation.cellGaps) || annotation.cellGaps.some(gap => typeof gap !== "string")))
|
|
349
|
+
)
|
|
350
|
+
return "";
|
|
351
|
+
found.value = { sourceId, annotation: annotation as CopyRowAnnotation };
|
|
352
|
+
} catch {
|
|
353
|
+
// Terminal data is untrusted at this boundary; ignore malformed APC data.
|
|
354
|
+
}
|
|
355
|
+
return "";
|
|
356
|
+
},
|
|
357
|
+
);
|
|
358
|
+
return found.value === null ? null : { line: clean, ...found.value, originColumn: originColumn ?? 0 };
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* Detach copy APCs from a row without decoding payloads: the visible line, the
|
|
363
|
+
* column of this process's origin marker, and the verbatim row APCs. Re-attaching
|
|
364
|
+
* `origin` at `originColumn` and `rows` after the content restores the annotations.
|
|
365
|
+
*/
|
|
366
|
+
export function splitCopyAnnotations(line: string): {
|
|
367
|
+
line: string;
|
|
368
|
+
origin: string;
|
|
369
|
+
originColumn: number;
|
|
370
|
+
rows: string;
|
|
371
|
+
} {
|
|
372
|
+
if (!line.includes(ANY_COPY_APC_MARKER)) return { line, origin: "", originColumn: 0, rows: "" };
|
|
373
|
+
let origin = "";
|
|
374
|
+
let originColumn = 0;
|
|
375
|
+
const withoutOrigin = line.replace(COPY_ORIGIN_REGEX, (marker, nonce: string, offset: number) => {
|
|
376
|
+
if (nonce === copyAnnotationNonce && !origin) {
|
|
377
|
+
origin = marker;
|
|
378
|
+
originColumn = visibleWidth(line.slice(0, offset));
|
|
379
|
+
}
|
|
380
|
+
return "";
|
|
381
|
+
});
|
|
382
|
+
let rows = "";
|
|
383
|
+
const clean = withoutOrigin.replace(COPY_ANNOTATION_REGEX, marker => {
|
|
384
|
+
rows += marker;
|
|
385
|
+
return "";
|
|
386
|
+
});
|
|
387
|
+
return { line: clean, origin, originColumn, rows };
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/** Rebind cached semantic row data to the component that emitted this frame. */
|
|
391
|
+
export function replaceCopyAnnotationSourceId(line: string, sourceId: string): string {
|
|
392
|
+
return line.replace(
|
|
393
|
+
COPY_ANNOTATION_REGEX,
|
|
394
|
+
(_marker, nonce: string, _oldSourceId: string, encoded: string) =>
|
|
395
|
+
`${COPY_ANNOTATION_PREFIX}${nonce}:${sourceId}:${encoded}${VIEWPORT_ANCHOR_SUFFIX}`,
|
|
396
|
+
);
|
|
397
|
+
}
|
|
202
398
|
|
|
203
399
|
function ansiSequenceEnd(text: string, start: number): number {
|
|
204
400
|
if (text[start] !== "\x1b" || start + 1 >= text.length) return start;
|