@adea-ai/ui 0.66.1 → 0.67.0

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/README.md CHANGED
@@ -373,6 +373,15 @@ imports and a 3 KiB gzip model budget (measured baseline: 2,455 bytes). This is
373
373
  model-subpath evidence; it does not waive UI #16's package attribution/conditional
374
374
  export corrections or the separate root compatibility gate.
375
375
 
376
+ `bun run check:packed-paste-model` installs the actual archive with peer and
377
+ optional packages omitted. It verifies the direct
378
+ `@adea-ai/ui/components/conversation/paste-tokens` entry in native Node and the
379
+ Solid source condition, a strict clean TypeScript consumer without ambient types,
380
+ round-trip/range/pruning and invalid-metadata behavior, and exact packed
381
+ LICENSE/NOTICE fidelity. The pure compiled module retains zero runtime imports
382
+ and stays within a 3 KiB gzip budget. This gate runs in local verification, CI
383
+ and publication; textarea editing and production adoption remain separate gates.
384
+
376
385
  ### Controlled appearance editor
377
386
 
378
387
  `AppearanceEditor` and `AppearancePopover` retain the accepted Zeron-derived
@@ -422,3 +431,15 @@ not just the editor primitive.
422
431
  Local packed interaction gates require `bunx playwright install chromium webkit`
423
432
  after dependency installation. On Linux, use `--with-deps` when the required
424
433
  system libraries are absent. All automation runs headless with disposable profiles.
434
+
435
+ ## Paste-token model (issue #532 selected unit)
436
+
437
+ `@adea-ai/ui/components/conversation` also exports the pure paste-token model:
438
+ `PasteBlock`, `isPasteBlock`, `countLines`, `shouldCollapse`, `nextSeq`,
439
+ `formatToken`, `findTokenRanges`, `tokenRangeAt`, `pruneBlocks`, `expandAll`,
440
+ `recollapsePastes`, `remapCarriedBlocks`, `stripTrailingBlankLines`, and the
441
+ threshold and marker constants. Hosts provide stable, bounded ASCII block IDs and own
442
+ draft state, persistence, message reconciliation and editor behavior. The model
443
+ does not read storage or the clipboard, upload files, edit a textarea, or
444
+ provide undo/history, previews or chips. Its selected source and Apache-2.0
445
+ attribution are recorded in the issue #532 entry in `NOTICE`.
package/dist/NOTICE CHANGED
@@ -267,3 +267,27 @@ pane-title gesture, replacing plaintext leaf IDs and string casts with typed
267
267
  intent, an instance-local active drag payload, and cancellation/removal checks.
268
268
  Header action composition injects application keyboard controls without owning
269
269
  shortcuts or domain transitions. No new upstream source or license is introduced.
270
+ ## Paste-token model translated from KiroCrew (issue #532)
271
+
272
+ `packages/ui/src/components/conversation/paste-tokens.ts` translates the pure
273
+ block and text transformations from `website/src/utils/pasteTokens.ts`, plus
274
+ the trailing-blank-line policy from `website/src/components/composerPastePolicy.ts`,
275
+ at KiroCrew revision `283e136c0f902e965a535a7c9548c57c7504fed0`.
276
+
277
+ Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
278
+ This product includes software developed at Amazon.com, Inc.
279
+ (https://www.amazon.com/). Licensed under the Apache License, Version 2.0.
280
+
281
+ Retained: the three-line/200-character collapse thresholds; canonical marker
282
+ formatting and ranges; monotonic sequence allocation and collision remapping;
283
+ token pruning, expansion and content-based re-collapse, including the
284
+ trailing-whitespace fallback; and the linear trailing-blank-line scan. Bun unit
285
+ cases adapt the pure expectations from `website/src/test/pasteTokens.test.ts` and
286
+ the trailing-text policy from `website/src/test/ChatInput.paste.test.tsx`.
287
+
288
+ The host supplies stable block IDs. Invalid IDs/sequences, duplicate identities,
289
+ ambiguous sequence mappings and noncanonical or line-mismatched markers are
290
+ refused. React, Lexical, browser clipboard/File handling, localStorage side-table
291
+ recovery, backend message reconciliation, uploads, textarea editing, previews,
292
+ chips, undo/history and submit/persistence authority are not included. This
293
+ selected model is not rich editing, complete Chat, or application adoption.
@@ -5,4 +5,5 @@ export { ConversationSurface, type ConversationSurfaceProps } from './conversati
5
5
  export { ComposerAttachmentButton, ComposerHint, MessageComposer, type MessageComposerProps, } from './message-composer';
6
6
  export { MessageBody, MessageDayDivider, MessageGroup, MessageRow, type MessageRowProps, } from './message-row';
7
7
  export { AttachmentCard, ThreadPanel, type AttachmentCardProps, type ThreadPanelProps, } from './thread-panel';
8
+ export { countLines, expandAll, findTokenRanges, formatToken, isPasteBlock, nextSeq, pruneBlocks, recollapsePastes, remapCarriedBlocks, shouldCollapse, stripTrailingBlankLines, tokenRangeAt, PASTE_THRESHOLD_CHARS, PASTE_THRESHOLD_LINES, PASTE_TOKEN_REGEX, type PasteBlock, } from './paste-tokens';
8
9
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/components/conversation/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,KAAK,iBAAiB,EAAE,KAAK,mBAAmB,EAAE,MAAM,iBAAiB,CAAA;AAChG,OAAO,EAAE,cAAc,EAAE,KAAK,YAAY,EAAE,KAAK,mBAAmB,EAAE,MAAM,oBAAoB,CAAA;AAChG,OAAO,EACL,kBAAkB,EAClB,KAAK,sBAAsB,EAC3B,KAAK,uBAAuB,GAC7B,MAAM,uBAAuB,CAAA;AAC9B,OAAO,EAAE,mBAAmB,EAAE,KAAK,wBAAwB,EAAE,MAAM,wBAAwB,CAAA;AAC3F,OAAO,EACL,wBAAwB,EACxB,YAAY,EACZ,eAAe,EACf,KAAK,oBAAoB,GAC1B,MAAM,oBAAoB,CAAA;AAC3B,OAAO,EACL,WAAW,EACX,iBAAiB,EACjB,YAAY,EACZ,UAAU,EACV,KAAK,eAAe,GACrB,MAAM,eAAe,CAAA;AACtB,OAAO,EACL,cAAc,EACd,WAAW,EACX,KAAK,mBAAmB,EACxB,KAAK,gBAAgB,GACtB,MAAM,gBAAgB,CAAA"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/components/conversation/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,KAAK,iBAAiB,EAAE,KAAK,mBAAmB,EAAE,MAAM,iBAAiB,CAAA;AAChG,OAAO,EAAE,cAAc,EAAE,KAAK,YAAY,EAAE,KAAK,mBAAmB,EAAE,MAAM,oBAAoB,CAAA;AAChG,OAAO,EACL,kBAAkB,EAClB,KAAK,sBAAsB,EAC3B,KAAK,uBAAuB,GAC7B,MAAM,uBAAuB,CAAA;AAC9B,OAAO,EAAE,mBAAmB,EAAE,KAAK,wBAAwB,EAAE,MAAM,wBAAwB,CAAA;AAC3F,OAAO,EACL,wBAAwB,EACxB,YAAY,EACZ,eAAe,EACf,KAAK,oBAAoB,GAC1B,MAAM,oBAAoB,CAAA;AAC3B,OAAO,EACL,WAAW,EACX,iBAAiB,EACjB,YAAY,EACZ,UAAU,EACV,KAAK,eAAe,GACrB,MAAM,eAAe,CAAA;AACtB,OAAO,EACL,cAAc,EACd,WAAW,EACX,KAAK,mBAAmB,EACxB,KAAK,gBAAgB,GACtB,MAAM,gBAAgB,CAAA;AAEvB,OAAO,EACL,UAAU,EACV,SAAS,EACT,eAAe,EACf,WAAW,EACX,YAAY,EACZ,OAAO,EACP,WAAW,EACX,gBAAgB,EAChB,kBAAkB,EAClB,cAAc,EACd,uBAAuB,EACvB,YAAY,EACZ,qBAAqB,EACrB,qBAAqB,EACrB,iBAAiB,EACjB,KAAK,UAAU,GAChB,MAAM,gBAAgB,CAAA"}
@@ -5,4 +5,5 @@ import { ConversationSurface } from "./conversation-surface.js";
5
5
  import { ComposerAttachmentButton, ComposerHint, MessageComposer } from "./message-composer.js";
6
6
  import { MessageBody, MessageDayDivider, MessageGroup, MessageRow } from "./message-row.js";
7
7
  import { AttachmentCard, ThreadPanel } from "./thread-panel.js";
8
- export { AttachmentCard, BusySendButton, ChatComposer, ComposerAttachmentButton, ComposerHint, ConversationAvatar, ConversationSurface, MessageBody, MessageComposer, MessageDayDivider, MessageGroup, MessageRow, ThreadPanel };
8
+ import { PASTE_THRESHOLD_CHARS, PASTE_THRESHOLD_LINES, PASTE_TOKEN_REGEX, countLines, expandAll, findTokenRanges, formatToken, isPasteBlock, nextSeq, pruneBlocks, recollapsePastes, remapCarriedBlocks, shouldCollapse, stripTrailingBlankLines, tokenRangeAt } from "./paste-tokens.js";
9
+ export { AttachmentCard, BusySendButton, ChatComposer, ComposerAttachmentButton, ComposerHint, ConversationAvatar, ConversationSurface, MessageBody, MessageComposer, MessageDayDivider, MessageGroup, MessageRow, PASTE_THRESHOLD_CHARS, PASTE_THRESHOLD_LINES, PASTE_TOKEN_REGEX, ThreadPanel, countLines, expandAll, findTokenRanges, formatToken, isPasteBlock, nextSeq, pruneBlocks, recollapsePastes, remapCarriedBlocks, shouldCollapse, stripTrailingBlankLines, tokenRangeAt };
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Pure paste-token transformations translated and hardened from KiroCrew's
3
+ * `website/src/utils/pasteTokens.ts` at revision
4
+ * `283e136c0f902e965a535a7c9548c57c7504fed0` (Apache-2.0). See the #532 entry
5
+ * in the repository NOTICE. Paste identity is supplied by the host; this module
6
+ * does not access storage, clipboard APIs, message services, or editor state.
7
+ */
8
+ /** A collapsed paste block stored alongside input or a message. */
9
+ export interface PasteBlock {
10
+ /** A stable host-provided ASCII identifier; never included in visible token text. */
11
+ id: string;
12
+ /** Positive safe integer, unique among blocks in the same text value. */
13
+ seq: number;
14
+ /** Positive safe integer displayed in the token. */
15
+ lines: number;
16
+ /** Original pasted text, retained verbatim. */
17
+ content: string;
18
+ }
19
+ export declare const PASTE_THRESHOLD_LINES = 3;
20
+ export declare const PASTE_THRESHOLD_CHARS = 200;
21
+ /** Canonical token pattern. Internal scans always use a fresh RegExp instance. */
22
+ export declare const PASTE_TOKEN_REGEX: RegExp;
23
+ /** Check data received from a host before using it as a paste-token block. */
24
+ export declare function isPasteBlock(value: unknown): value is PasteBlock;
25
+ export declare function formatToken(block: PasteBlock): string;
26
+ export declare function shouldCollapse(text: string): boolean;
27
+ export declare function countLines(text: string): number;
28
+ /** Next sequence for a new paste = max existing + 1, starting at 1. */
29
+ export declare function nextSeq(blocks: readonly PasteBlock[]): number;
30
+ /**
31
+ * Re-sequence `carried` blocks whose sequence is already taken by `used`, and
32
+ * rewrite their markers in one right-to-left pass. Carried block IDs and
33
+ * sequences must be unique so a visible marker always has one backing block.
34
+ * `used` is mutated to include the assigned sequences.
35
+ */
36
+ export declare function remapCarriedBlocks(text: string, carried: readonly PasteBlock[], used: Set<number>): {
37
+ text: string;
38
+ blocks: PasteBlock[];
39
+ };
40
+ /** Ranges for canonical tokens whose sequence and line count match one block. */
41
+ export declare function findTokenRanges(text: string, blocks: readonly PasteBlock[]): Array<{
42
+ start: number;
43
+ end: number;
44
+ block: PasteBlock;
45
+ }>;
46
+ export declare function tokenRangeAt(text: string, blocks: readonly PasteBlock[], caret: number): {
47
+ start: number;
48
+ end: number;
49
+ block: PasteBlock;
50
+ } | null;
51
+ export declare function pruneBlocks(text: string, blocks: PasteBlock[]): PasteBlock[];
52
+ export declare function expandAll(text: string, blocks: readonly PasteBlock[]): string;
53
+ /** Inverse of `expandAll`: replace each first non-overlapping verbatim block. */
54
+ export declare function recollapsePastes(content: string, blocks: readonly PasteBlock[]): string;
55
+ /** Strip a trailing whitespace run only when that run contains a newline. */
56
+ export declare function stripTrailingBlankLines(value: string): string;
57
+ //# sourceMappingURL=paste-tokens.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"paste-tokens.d.ts","sourceRoot":"","sources":["../../../src/components/conversation/paste-tokens.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,mEAAmE;AACnE,MAAM,WAAW,UAAU;IACzB,qFAAqF;IACrF,EAAE,EAAE,MAAM,CAAA;IACV,yEAAyE;IACzE,GAAG,EAAE,MAAM,CAAA;IACX,oDAAoD;IACpD,KAAK,EAAE,MAAM,CAAA;IACb,+CAA+C;IAC/C,OAAO,EAAE,MAAM,CAAA;CAChB;AAED,eAAO,MAAM,qBAAqB,IAAI,CAAA;AACtC,eAAO,MAAM,qBAAqB,MAAM,CAAA;AAMxC,kFAAkF;AAClF,eAAO,MAAM,iBAAiB,QAAsC,CAAA;AAMpE,8EAA8E;AAC9E,wBAAgB,YAAY,CAAC,KAAK,EAAE,OAAO,GAAG,KAAK,IAAI,UAAU,CAUhE;AAaD,wBAAgB,WAAW,CAAC,KAAK,EAAE,UAAU,GAAG,MAAM,CAGrD;AAED,wBAAgB,cAAc,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAGpD;AAED,wBAAgB,UAAU,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAG/C;AAED,uEAAuE;AACvE,wBAAgB,OAAO,CAAC,MAAM,EAAE,SAAS,UAAU,EAAE,GAAG,MAAM,CAO7D;AAED;;;;;GAKG;AACH,wBAAgB,kBAAkB,CAChC,IAAI,EAAE,MAAM,EACZ,OAAO,EAAE,SAAS,UAAU,EAAE,EAC9B,IAAI,EAAE,GAAG,CAAC,MAAM,CAAC,GAChB;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,UAAU,EAAE,CAAA;CAAE,CAiDxC;AAED,iFAAiF;AACjF,wBAAgB,eAAe,CAC7B,IAAI,EAAE,MAAM,EACZ,MAAM,EAAE,SAAS,UAAU,EAAE,GAC5B,KAAK,CAAC;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,GAAG,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,UAAU,CAAA;CAAE,CAAC,CAkB1D;AAED,wBAAgB,YAAY,CAC1B,IAAI,EAAE,MAAM,EACZ,MAAM,EAAE,SAAS,UAAU,EAAE,EAC7B,KAAK,EAAE,MAAM,GACZ;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,GAAG,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,UAAU,CAAA;CAAE,GAAG,IAAI,CAM1D;AAED,wBAAgB,WAAW,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,UAAU,EAAE,GAAG,UAAU,EAAE,CAQ5E;AAED,wBAAgB,SAAS,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,SAAS,UAAU,EAAE,GAAG,MAAM,CAU7E;AAED,iFAAiF;AACjF,wBAAgB,gBAAgB,CAAC,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,SAAS,UAAU,EAAE,GAAG,MAAM,CAgDvF;AAED,6EAA6E;AAC7E,wBAAgB,uBAAuB,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAiB7D"}
@@ -0,0 +1,216 @@
1
+ //#region src/components/conversation/paste-tokens.ts
2
+ var PASTE_THRESHOLD_LINES = 3;
3
+ var PASTE_THRESHOLD_CHARS = 200;
4
+ var PASTE_ID_PATTERN = /^[A-Za-z0-9._:-]{1,128}$/;
5
+ var MAX_SAFE_SEQUENCE_TEXT_LENGTH = String(Number.MAX_SAFE_INTEGER).length;
6
+ var PASTE_TOKEN_SOURCE = String.raw`\[ Paste #([1-9]\d{0,${MAX_SAFE_SEQUENCE_TEXT_LENGTH - 1}}) · ([1-9]\d{0,${MAX_SAFE_SEQUENCE_TEXT_LENGTH - 1}}) lines \]`;
7
+ /** Canonical token pattern. Internal scans always use a fresh RegExp instance. */
8
+ var PASTE_TOKEN_REGEX = new RegExp(PASTE_TOKEN_SOURCE, "g");
9
+ function isPositiveSafeInteger(value) {
10
+ return Number.isSafeInteger(value) && value > 0;
11
+ }
12
+ /** Check data received from a host before using it as a paste-token block. */
13
+ function isPasteBlock(value) {
14
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
15
+ const candidate = value;
16
+ return typeof candidate.id === "string" && PASTE_ID_PATTERN.test(candidate.id) && isPositiveSafeInteger(candidate.seq) && isPositiveSafeInteger(candidate.lines) && typeof candidate.content === "string";
17
+ }
18
+ function unambiguousBlocks(blocks) {
19
+ const valid = blocks.filter(isPasteBlock);
20
+ const seqCounts = /* @__PURE__ */ new Map();
21
+ const idCounts = /* @__PURE__ */ new Map();
22
+ for (const block of valid) {
23
+ seqCounts.set(block.seq, (seqCounts.get(block.seq) ?? 0) + 1);
24
+ idCounts.set(block.id, (idCounts.get(block.id) ?? 0) + 1);
25
+ }
26
+ return valid.filter((block) => seqCounts.get(block.seq) === 1 && idCounts.get(block.id) === 1);
27
+ }
28
+ function formatToken(block) {
29
+ if (!isPasteBlock(block)) throw new TypeError("Cannot format an invalid paste block");
30
+ return `[ Paste #${block.seq} · ${block.lines} lines ]`;
31
+ }
32
+ function shouldCollapse(text) {
33
+ if (!text) return false;
34
+ return countLines(text) >= 3 || text.length >= 200;
35
+ }
36
+ function countLines(text) {
37
+ if (!text) return 0;
38
+ return text.split("\n").length;
39
+ }
40
+ /** Next sequence for a new paste = max existing + 1, starting at 1. */
41
+ function nextSeq(blocks) {
42
+ let max = 0;
43
+ for (const block of blocks) if (isPasteBlock(block) && block.seq > max) max = block.seq;
44
+ if (max === Number.MAX_SAFE_INTEGER) throw new RangeError("Paste sequence space is exhausted");
45
+ return max + 1;
46
+ }
47
+ /**
48
+ * Re-sequence `carried` blocks whose sequence is already taken by `used`, and
49
+ * rewrite their markers in one right-to-left pass. Carried block IDs and
50
+ * sequences must be unique so a visible marker always has one backing block.
51
+ * `used` is mutated to include the assigned sequences.
52
+ */
53
+ function remapCarriedBlocks(text, carried, used) {
54
+ const seenSeqs = /* @__PURE__ */ new Set();
55
+ const seenIds = /* @__PURE__ */ new Set();
56
+ for (const block of carried) {
57
+ if (!isPasteBlock(block)) throw new TypeError("Cannot remap an invalid paste block");
58
+ if (seenSeqs.has(block.seq) || seenIds.has(block.id)) throw new TypeError("Carried paste block IDs and sequences must be unique");
59
+ seenSeqs.add(block.seq);
60
+ seenIds.add(block.id);
61
+ }
62
+ let maxUsed = 0;
63
+ for (const value of used) if (isPositiveSafeInteger(value) && value > maxUsed) maxUsed = value;
64
+ let free = maxUsed < Number.MAX_SAFE_INTEGER ? maxUsed + 1 : 1;
65
+ const allocateFree = () => {
66
+ while (used.has(free)) free = free < Number.MAX_SAFE_INTEGER ? free + 1 : 1;
67
+ const assigned = free;
68
+ used.add(assigned);
69
+ free = assigned < Number.MAX_SAFE_INTEGER ? assigned + 1 : 1;
70
+ return assigned;
71
+ };
72
+ const remap = /* @__PURE__ */ new Map();
73
+ const blocks = carried.map((block) => {
74
+ if (!used.has(block.seq)) {
75
+ used.add(block.seq);
76
+ return block;
77
+ }
78
+ const seq = allocateFree();
79
+ remap.set(block.seq, seq);
80
+ return {
81
+ ...block,
82
+ seq
83
+ };
84
+ });
85
+ if (!remap.size) return {
86
+ text,
87
+ blocks
88
+ };
89
+ let out = text;
90
+ const ranges = findTokenRanges(text, carried);
91
+ for (let index = ranges.length - 1; index >= 0; index -= 1) {
92
+ const { start, end, block } = ranges[index];
93
+ const mapped = remap.get(block.seq);
94
+ if (mapped === void 0) continue;
95
+ out = out.slice(0, start) + formatToken({
96
+ ...block,
97
+ seq: mapped
98
+ }) + out.slice(end);
99
+ }
100
+ return {
101
+ text: out,
102
+ blocks
103
+ };
104
+ }
105
+ /** Ranges for canonical tokens whose sequence and line count match one block. */
106
+ function findTokenRanges(text, blocks) {
107
+ if (!text || !blocks.length) return [];
108
+ const bySeq = new Map(unambiguousBlocks(blocks).map((block) => [block.seq, block]));
109
+ if (!bySeq.size) return [];
110
+ const tokenRegex = new RegExp(PASTE_TOKEN_SOURCE, "g");
111
+ const ranges = [];
112
+ let match;
113
+ while ((match = tokenRegex.exec(text)) !== null) {
114
+ const seq = Number(match[1]);
115
+ const lines = Number(match[2]);
116
+ if (!isPositiveSafeInteger(seq) || !isPositiveSafeInteger(lines)) continue;
117
+ const block = bySeq.get(seq);
118
+ if (block && block.lines === lines) ranges.push({
119
+ start: match.index,
120
+ end: match.index + match[0].length,
121
+ block
122
+ });
123
+ }
124
+ return ranges;
125
+ }
126
+ function tokenRangeAt(text, blocks, caret) {
127
+ if (!Number.isSafeInteger(caret) || caret < 0 || caret > text.length) return null;
128
+ for (const range of findTokenRanges(text, blocks)) if (caret >= range.start && caret <= range.end) return range;
129
+ return null;
130
+ }
131
+ function pruneBlocks(text, blocks) {
132
+ if (!blocks.length) return blocks;
133
+ const usable = unambiguousBlocks(blocks);
134
+ const survivors = new Set(findTokenRanges(text, usable).map((range) => range.block.id));
135
+ const next = usable.filter((block) => survivors.has(block.id));
136
+ return next.length === blocks.length && next.every((block, index) => block === blocks[index]) ? blocks : next;
137
+ }
138
+ function expandAll(text, blocks) {
139
+ if (!text || !blocks.length) return text;
140
+ const ranges = findTokenRanges(text, blocks);
141
+ if (!ranges.length) return text;
142
+ let out = text;
143
+ for (let index = ranges.length - 1; index >= 0; index -= 1) {
144
+ const range = ranges[index];
145
+ out = out.slice(0, range.start) + range.block.content + out.slice(range.end);
146
+ }
147
+ return out;
148
+ }
149
+ /** Inverse of `expandAll`: replace each first non-overlapping verbatim block. */
150
+ function recollapsePastes(content, blocks) {
151
+ if (!content || !blocks.length) return content;
152
+ const hits = [];
153
+ const claimed = [];
154
+ const firstUnclaimed = (needle) => {
155
+ if (!needle) return -1;
156
+ let from = 0;
157
+ while (from <= content.length) {
158
+ const index = content.indexOf(needle, from);
159
+ if (index < 0) return -1;
160
+ if (!claimed.some(([start, end]) => index < end && index + needle.length > start)) return index;
161
+ from = index + 1;
162
+ }
163
+ return -1;
164
+ };
165
+ for (const block of unambiguousBlocks(blocks)) {
166
+ if (!block.content) continue;
167
+ const trimmed = block.content.trimEnd();
168
+ let needle = block.content;
169
+ let index = firstUnclaimed(needle);
170
+ if (index < 0 && trimmed && trimmed !== block.content) {
171
+ needle = trimmed;
172
+ index = firstUnclaimed(needle);
173
+ }
174
+ if (index < 0) continue;
175
+ const end = index + needle.length;
176
+ hits.push({
177
+ start: index,
178
+ end,
179
+ block
180
+ });
181
+ claimed.push([index, end]);
182
+ }
183
+ if (!hits.length) return content;
184
+ hits.sort((left, right) => left.start - right.start);
185
+ let out = "";
186
+ let position = 0;
187
+ for (const hit of hits) {
188
+ if (hit.start < position) continue;
189
+ out += content.slice(position, hit.start) + formatToken(hit.block);
190
+ position = hit.end;
191
+ }
192
+ return out + content.slice(position);
193
+ }
194
+ /** Strip a trailing whitespace run only when that run contains a newline. */
195
+ function stripTrailingBlankLines(value) {
196
+ let index = value.length - 1;
197
+ let sawNewline = false;
198
+ while (index >= 0) {
199
+ const code = value.charCodeAt(index);
200
+ if (code === 10 || code === 13) {
201
+ sawNewline = true;
202
+ index -= 1;
203
+ continue;
204
+ }
205
+ if (code === 32 || code === 9) {
206
+ index -= 1;
207
+ continue;
208
+ }
209
+ break;
210
+ }
211
+ return sawNewline ? value.slice(0, index + 1) : value;
212
+ }
213
+ //#endregion
214
+ export { PASTE_THRESHOLD_CHARS, PASTE_THRESHOLD_LINES, PASTE_TOKEN_REGEX, countLines, expandAll, findTokenRanges, formatToken, isPasteBlock, nextSeq, pruneBlocks, recollapsePastes, remapCarriedBlocks, shouldCollapse, stripTrailingBlankLines, tokenRangeAt };
215
+
216
+ //# sourceMappingURL=paste-tokens.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"paste-tokens.js","names":[],"sources":["../../../src/components/conversation/paste-tokens.ts"],"sourcesContent":["/**\n * Pure paste-token transformations translated and hardened from KiroCrew's\n * `website/src/utils/pasteTokens.ts` at revision\n * `283e136c0f902e965a535a7c9548c57c7504fed0` (Apache-2.0). See the #532 entry\n * in the repository NOTICE. Paste identity is supplied by the host; this module\n * does not access storage, clipboard APIs, message services, or editor state.\n */\n\n/** A collapsed paste block stored alongside input or a message. */\nexport interface PasteBlock {\n /** A stable host-provided ASCII identifier; never included in visible token text. */\n id: string\n /** Positive safe integer, unique among blocks in the same text value. */\n seq: number\n /** Positive safe integer displayed in the token. */\n lines: number\n /** Original pasted text, retained verbatim. */\n content: string\n}\n\nexport const PASTE_THRESHOLD_LINES = 3\nexport const PASTE_THRESHOLD_CHARS = 200\n\nconst PASTE_ID_PATTERN = /^[A-Za-z0-9._:-]{1,128}$/\nconst MAX_SAFE_SEQUENCE_TEXT_LENGTH = String(Number.MAX_SAFE_INTEGER).length\nconst PASTE_TOKEN_SOURCE = String.raw`\\[ Paste #([1-9]\\d{0,${MAX_SAFE_SEQUENCE_TEXT_LENGTH - 1}}) · ([1-9]\\d{0,${MAX_SAFE_SEQUENCE_TEXT_LENGTH - 1}}) lines \\]`\n\n/** Canonical token pattern. Internal scans always use a fresh RegExp instance. */\nexport const PASTE_TOKEN_REGEX = new RegExp(PASTE_TOKEN_SOURCE, 'g')\n\nfunction isPositiveSafeInteger(value: unknown): value is number {\n return Number.isSafeInteger(value) && (value as number) > 0\n}\n\n/** Check data received from a host before using it as a paste-token block. */\nexport function isPasteBlock(value: unknown): value is PasteBlock {\n if (typeof value !== 'object' || value === null || Array.isArray(value)) return false\n const candidate = value as Record<string, unknown>\n return (\n typeof candidate.id === 'string' &&\n PASTE_ID_PATTERN.test(candidate.id) &&\n isPositiveSafeInteger(candidate.seq) &&\n isPositiveSafeInteger(candidate.lines) &&\n typeof candidate.content === 'string'\n )\n}\n\nfunction unambiguousBlocks(blocks: readonly PasteBlock[]): PasteBlock[] {\n const valid = blocks.filter(isPasteBlock)\n const seqCounts = new Map<number, number>()\n const idCounts = new Map<string, number>()\n for (const block of valid) {\n seqCounts.set(block.seq, (seqCounts.get(block.seq) ?? 0) + 1)\n idCounts.set(block.id, (idCounts.get(block.id) ?? 0) + 1)\n }\n return valid.filter((block) => seqCounts.get(block.seq) === 1 && idCounts.get(block.id) === 1)\n}\n\nexport function formatToken(block: PasteBlock): string {\n if (!isPasteBlock(block)) throw new TypeError('Cannot format an invalid paste block')\n return `[ Paste #${block.seq} · ${block.lines} lines ]`\n}\n\nexport function shouldCollapse(text: string): boolean {\n if (!text) return false\n return countLines(text) >= PASTE_THRESHOLD_LINES || text.length >= PASTE_THRESHOLD_CHARS\n}\n\nexport function countLines(text: string): number {\n if (!text) return 0\n return text.split('\\n').length\n}\n\n/** Next sequence for a new paste = max existing + 1, starting at 1. */\nexport function nextSeq(blocks: readonly PasteBlock[]): number {\n let max = 0\n for (const block of blocks) {\n if (isPasteBlock(block) && block.seq > max) max = block.seq\n }\n if (max === Number.MAX_SAFE_INTEGER) throw new RangeError('Paste sequence space is exhausted')\n return max + 1\n}\n\n/**\n * Re-sequence `carried` blocks whose sequence is already taken by `used`, and\n * rewrite their markers in one right-to-left pass. Carried block IDs and\n * sequences must be unique so a visible marker always has one backing block.\n * `used` is mutated to include the assigned sequences.\n */\nexport function remapCarriedBlocks(\n text: string,\n carried: readonly PasteBlock[],\n used: Set<number>\n): { text: string; blocks: PasteBlock[] } {\n const seenSeqs = new Set<number>()\n const seenIds = new Set<string>()\n for (const block of carried) {\n if (!isPasteBlock(block)) throw new TypeError('Cannot remap an invalid paste block')\n if (seenSeqs.has(block.seq) || seenIds.has(block.id)) {\n throw new TypeError('Carried paste block IDs and sequences must be unique')\n }\n seenSeqs.add(block.seq)\n seenIds.add(block.id)\n }\n\n let maxUsed = 0\n for (const value of used) {\n if (isPositiveSafeInteger(value) && value > maxUsed) maxUsed = value\n }\n let free = maxUsed < Number.MAX_SAFE_INTEGER ? maxUsed + 1 : 1\n const allocateFree = (): number => {\n while (used.has(free)) {\n free = free < Number.MAX_SAFE_INTEGER ? free + 1 : 1\n }\n const assigned = free\n used.add(assigned)\n free = assigned < Number.MAX_SAFE_INTEGER ? assigned + 1 : 1\n return assigned\n }\n\n const remap = new Map<number, number>()\n const blocks = carried.map((block) => {\n if (!used.has(block.seq)) {\n used.add(block.seq)\n return block\n }\n const seq = allocateFree()\n remap.set(block.seq, seq)\n return { ...block, seq }\n })\n\n if (!remap.size) return { text, blocks }\n\n let out = text\n const ranges = findTokenRanges(text, carried)\n for (let index = ranges.length - 1; index >= 0; index -= 1) {\n const { start, end, block } = ranges[index]!\n const mapped = remap.get(block.seq)\n if (mapped === undefined) continue\n out = out.slice(0, start) + formatToken({ ...block, seq: mapped }) + out.slice(end)\n }\n return { text: out, blocks }\n}\n\n/** Ranges for canonical tokens whose sequence and line count match one block. */\nexport function findTokenRanges(\n text: string,\n blocks: readonly PasteBlock[]\n): Array<{ start: number; end: number; block: PasteBlock }> {\n if (!text || !blocks.length) return []\n const bySeq = new Map(unambiguousBlocks(blocks).map((block) => [block.seq, block]))\n if (!bySeq.size) return []\n\n const tokenRegex = new RegExp(PASTE_TOKEN_SOURCE, 'g')\n const ranges: Array<{ start: number; end: number; block: PasteBlock }> = []\n let match: RegExpExecArray | null\n while ((match = tokenRegex.exec(text)) !== null) {\n const seq = Number(match[1])\n const lines = Number(match[2])\n if (!isPositiveSafeInteger(seq) || !isPositiveSafeInteger(lines)) continue\n const block = bySeq.get(seq)\n if (block && block.lines === lines) {\n ranges.push({ start: match.index, end: match.index + match[0].length, block })\n }\n }\n return ranges\n}\n\nexport function tokenRangeAt(\n text: string,\n blocks: readonly PasteBlock[],\n caret: number\n): { start: number; end: number; block: PasteBlock } | null {\n if (!Number.isSafeInteger(caret) || caret < 0 || caret > text.length) return null\n for (const range of findTokenRanges(text, blocks)) {\n if (caret >= range.start && caret <= range.end) return range\n }\n return null\n}\n\nexport function pruneBlocks(text: string, blocks: PasteBlock[]): PasteBlock[] {\n if (!blocks.length) return blocks\n const usable = unambiguousBlocks(blocks)\n const survivors = new Set(findTokenRanges(text, usable).map((range) => range.block.id))\n const next = usable.filter((block) => survivors.has(block.id))\n return next.length === blocks.length && next.every((block, index) => block === blocks[index])\n ? blocks\n : next\n}\n\nexport function expandAll(text: string, blocks: readonly PasteBlock[]): string {\n if (!text || !blocks.length) return text\n const ranges = findTokenRanges(text, blocks)\n if (!ranges.length) return text\n let out = text\n for (let index = ranges.length - 1; index >= 0; index -= 1) {\n const range = ranges[index]!\n out = out.slice(0, range.start) + range.block.content + out.slice(range.end)\n }\n return out\n}\n\n/** Inverse of `expandAll`: replace each first non-overlapping verbatim block. */\nexport function recollapsePastes(content: string, blocks: readonly PasteBlock[]): string {\n if (!content || !blocks.length) return content\n\n interface Hit {\n start: number\n end: number\n block: PasteBlock\n }\n const hits: Hit[] = []\n const claimed: Array<[number, number]> = []\n const firstUnclaimed = (needle: string): number => {\n if (!needle) return -1\n let from = 0\n while (from <= content.length) {\n const index = content.indexOf(needle, from)\n if (index < 0) return -1\n if (!claimed.some(([start, end]) => index < end && index + needle.length > start))\n return index\n from = index + 1\n }\n return -1\n }\n\n for (const block of unambiguousBlocks(blocks)) {\n if (!block.content) continue\n const trimmed = block.content.trimEnd()\n let needle = block.content\n let index = firstUnclaimed(needle)\n if (index < 0 && trimmed && trimmed !== block.content) {\n needle = trimmed\n index = firstUnclaimed(needle)\n }\n if (index < 0) continue\n const end = index + needle.length\n hits.push({ start: index, end, block })\n claimed.push([index, end])\n }\n\n if (!hits.length) return content\n hits.sort((left, right) => left.start - right.start)\n let out = ''\n let position = 0\n for (const hit of hits) {\n if (hit.start < position) continue\n out += content.slice(position, hit.start) + formatToken(hit.block)\n position = hit.end\n }\n return out + content.slice(position)\n}\n\n/** Strip a trailing whitespace run only when that run contains a newline. */\nexport function stripTrailingBlankLines(value: string): string {\n let index = value.length - 1\n let sawNewline = false\n while (index >= 0) {\n const code = value.charCodeAt(index)\n if (code === 10 || code === 13) {\n sawNewline = true\n index -= 1\n continue\n }\n if (code === 32 || code === 9) {\n index -= 1\n continue\n }\n break\n }\n return sawNewline ? value.slice(0, index + 1) : value\n}\n"],"mappings":"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"}
package/dist/index.js CHANGED
@@ -77,6 +77,7 @@ import { ConversationSurface } from "./components/conversation/conversation-surf
77
77
  import { ComposerAttachmentButton, ComposerHint, MessageComposer } from "./components/conversation/message-composer.js";
78
78
  import { MessageBody, MessageDayDivider, MessageGroup, MessageRow } from "./components/conversation/message-row.js";
79
79
  import { AttachmentCard, ThreadPanel } from "./components/conversation/thread-panel.js";
80
+ import { PASTE_THRESHOLD_CHARS, PASTE_THRESHOLD_LINES, PASTE_TOKEN_REGEX, countLines, expandAll, findTokenRanges, formatToken, isPasteBlock, nextSeq, pruneBlocks, recollapsePastes, remapCarriedBlocks, shouldCollapse, stripTrailingBlankLines, tokenRangeAt } from "./components/conversation/paste-tokens.js";
80
81
  import { accentPresets, allTokens, colorTokens, densityTokens, designTokens, elevationTokens, fontOptions, motionTokens, radiusTokens, typographyTokens, undocumentedTokenAliases, zIndexTokens } from "./lib/tokens.js";
81
82
  import { builtinThemes, contrastRatio, defaultDarkThemeId, defaultLightThemeId, themeById, themeCssVariables, themeFamilies, themesForAppearance, validateTheme, validateThemeRegistry } from "./lib/themes.js";
82
83
  import { ThemePicker, ThemePreview, ThemeSwatch } from "./components/theme/theme-picker.js";
@@ -93,4 +94,4 @@ import { formatBytes, formatReleaseDate, plainTextFromMarkdown } from "./lib/ver
93
94
  import { UpdateDialog } from "./components/composites/update-dialog/update-dialog.js";
94
95
  import { WorkspaceMark } from "./components/composites/workspace-mark/workspace-mark.js";
95
96
  import { keyedRows } from "./lib/keyed-rows.js";
96
- export { Accordion, AccordionContent, AccordionItem, AccordionTrigger, AccountMenu, Alert, AlertDescription, AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogOverlay, AlertDialogPortal, AlertDialogTitle, AlertDialogTrigger, AlertTitle, AppShell, AppShellBody, AppShellContent, AppShellMain, AppearanceEditor, AppearancePanel, AppearancePopover, AspectRatio, AttachmentCard, Avatar, AvatarFallback, AvatarImage, Badge, Board, BoardCardBody, BoardCardTitle, Breadcrumb, BreadcrumbEllipsis, BreadcrumbItem, BreadcrumbLink, BreadcrumbList, BreadcrumbSeparator, BusySendButton, Button, ButtonGroup, ButtonGroupSeparator, ButtonGroupText, CalendarSurface, Card, CardAction, CardContent, CardDescription, CardFooter, CardHeader, CardTitle, ChatComposer, Checkbox, CheckboxDescription, CheckboxLabel, CodeBlock, Collapsible, CollapsibleContent, CollapsibleTrigger, Combobox, ComboboxClear, ComboboxContent, ComboboxControl, ComboboxInput, ComboboxItem, ComboboxSection, ComboboxTrigger, Command, CommandDialog, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, CommandLoading, CommandSeparator, CommandShortcut, ComposerAttachmentButton, ComposerHint, ContextMenu, ContextMenuCheckboxItem, ContextMenuContent, ContextMenuGroup, ContextMenuItem, ContextMenuLabel, ContextMenuRadioGroup, ContextMenuRadioItem, ContextMenuSeparator, ContextMenuSub, ContextMenuSubContent, ContextMenuSubTrigger, ContextMenuTrigger, ConversationAvatar, ConversationSurface, DatePicker, DetailPanel, DetailPanelBody, DetailPanelField, DetailPanelHeader, DetailPanelSection, Dialog, DialogClose, DialogCloseButton, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogOverlay, DialogPortal, DialogTitle, DialogTrigger, DiffBlock, DiffSummary, Drawer, DrawerBody, DrawerCloseButton, DrawerContent, DrawerDescription, DrawerFooter, DrawerHeader, DrawerOverlay, DrawerTitle, DrawerTrigger, DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuLabel, DropdownMenuPortal, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuSeparator, DropdownMenuShortcut, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuTrigger, Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle, EntityIcon, Field, FieldDescription, FieldError, FieldInput, FieldLabel, FieldLegend, FieldSeparator, FieldSet, FieldTextArea, HoverCard, HoverCardContent, HoverCardTrigger, InlineCode, Input, InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput, InputGroupText, InputOtp, InputOtpCaret, Item, ItemDescription, ItemEmpty, ItemGroup, ItemGroupEntry, ItemTitle, Kbd, KbdGroup, Label, ListGroup, ListRow, MAX_LAYOUT_DEPTH, MAX_LAYOUT_LEAVES, MAX_SPLIT_RATIO, MIN_SPLIT_RATIO, Menubar, MenubarCheckboxItem, MenubarContent, MenubarGroup, MenubarItem, MenubarLabel, MenubarMenu, MenubarRadioGroup, MenubarRadioItem, MenubarSeparator, MenubarSub, MenubarSubContent, MenubarSubTrigger, MenubarTrigger, MessageBody, MessageComposer, MessageDayDivider, MessageGroup, MessageRow, ModalDialog, Motion, NavigationMenu, NavigationMenuContent, NavigationMenuGroup, NavigationMenuGroupLabel, NavigationMenuItem, NavigationMenuMenu, NavigationMenuSeparator, NavigationMenuTrigger, NavigationMenuViewport, Page, PageHeader, PageHeaderActions, PageHeaderContent, PageHeaderDescription, PageHeaderTitle, PageSection, Pagination, PaginationEllipsis, PaginationItem, PaginationNext, PaginationPrevious, PalettePreview, Panel, PanelActions, PanelBody, PanelCard, PanelDescription, PanelFooter, PanelHeader, PanelPlaceholder, PanelTitle, PanelToolbar, Popover, PopoverAnchor, PopoverCloseButton, PopoverContent, PopoverDescription, PopoverTitle, PopoverTrigger, Presence, Progress, PropertyList, PropertyRow, PropertyTerm, PropertyValue, RadioGroup, RadioGroupItem, ResizableHandle, ResizablePanel, ResizablePanelGroup, ScrollArea, Select, SelectChevronsUpDown, SelectContent, SelectItem, SelectLabel, SelectSection, SelectTrigger, SelectValue, Separator, SettingsField, SettingsPage, SettingsRow, SettingsSection, Sheet, SheetBody, SheetCloseButton, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle, SheetTrigger, SideRail, SideRailButton, SideRailContent, SideRailFooter, SideRailHeader, SideRailItem, SideRailSection, SidebarNav, SidebarNavButton, SidebarNavContent, SidebarNavFooter, SidebarNavHeader, SidebarNavItem, SidebarNavSection, SidebarNavTitle, Skeleton, Slider, Spinner, SplitLayout, Stat, StatGroup, StatusBar, StatusBarItem, StatusBarSpacer, StatusChip, StatusList, Switch, SwitchDescription, SwitchLabel, TALL_CODE_BLOCK_PX, Table, TableBody, TableCaption, TableCell, TableFooter, TableHead, TableHeader, TableNumericCell, TableRow, Tabs, TabsContent, TabsList, TabsTrigger, Textarea, ThemeMiniature, ThemeMiniatureSplit, ThemePicker, ThemePreview, ThemeProvider, ThemeSwatch, ThemeToggle, ThreadPanel, Toaster, Toggle, ToggleGroup, ToggleGroupItem, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, TopBar, TopBarBreadcrumb, TopBarPill, TopBarSearch, TopBarSection, TopBarTitle, UpdateDialog, WorkspaceMark, accentPresets, alertVariants, allTokens, badgeVariants, boardCardTitleVariants, builtinThemes, buttonVariants, closePane, cn, colorTokens, computeLayoutFrames, contrastRatio, controlInteractive, controlSize, controlSizeIcon, controlSizes, countLeaves, createLayoutState, cva, defaultDarkThemeId, defaultLightThemeId, defaultThemeSelection, densityTokens, designTokens, diffLineVariants, diffStats, diffTargetPath, elevationTokens, entityIconVariants, focusPane, fontOptions, formatBytes, formatReleaseDate, initialsFrom, inlineScriptLiteral, itemVariants, keyedRows, layoutDepth, listLeaves, monogram, motionTokens, movePane, neighborLeaf, normalizeLayout, parseDiff, plainTextFromMarkdown, radiusTokens, resizeSplit, resolvedDensity, sheetVariants, sidebarNavItemClass, sidebarNavItemStateClass, splitPane, statusDotVariants, surfaceInteractive, swapPanes, themeById, themeCssVariables, themeFamilies, themeScript, themesForAppearance, toast, toaster, toggleVariants, typographyTokens, undoClosePane, undocumentedTokenAliases, useSideRail, useTheme, validateTheme, validateThemeRegistry, zIndexTokens };
97
+ export { Accordion, AccordionContent, AccordionItem, AccordionTrigger, AccountMenu, Alert, AlertDescription, AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogOverlay, AlertDialogPortal, AlertDialogTitle, AlertDialogTrigger, AlertTitle, AppShell, AppShellBody, AppShellContent, AppShellMain, AppearanceEditor, AppearancePanel, AppearancePopover, AspectRatio, AttachmentCard, Avatar, AvatarFallback, AvatarImage, Badge, Board, BoardCardBody, BoardCardTitle, Breadcrumb, BreadcrumbEllipsis, BreadcrumbItem, BreadcrumbLink, BreadcrumbList, BreadcrumbSeparator, BusySendButton, Button, ButtonGroup, ButtonGroupSeparator, ButtonGroupText, CalendarSurface, Card, CardAction, CardContent, CardDescription, CardFooter, CardHeader, CardTitle, ChatComposer, Checkbox, CheckboxDescription, CheckboxLabel, CodeBlock, Collapsible, CollapsibleContent, CollapsibleTrigger, Combobox, ComboboxClear, ComboboxContent, ComboboxControl, ComboboxInput, ComboboxItem, ComboboxSection, ComboboxTrigger, Command, CommandDialog, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, CommandLoading, CommandSeparator, CommandShortcut, ComposerAttachmentButton, ComposerHint, ContextMenu, ContextMenuCheckboxItem, ContextMenuContent, ContextMenuGroup, ContextMenuItem, ContextMenuLabel, ContextMenuRadioGroup, ContextMenuRadioItem, ContextMenuSeparator, ContextMenuSub, ContextMenuSubContent, ContextMenuSubTrigger, ContextMenuTrigger, ConversationAvatar, ConversationSurface, DatePicker, DetailPanel, DetailPanelBody, DetailPanelField, DetailPanelHeader, DetailPanelSection, Dialog, DialogClose, DialogCloseButton, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogOverlay, DialogPortal, DialogTitle, DialogTrigger, DiffBlock, DiffSummary, Drawer, DrawerBody, DrawerCloseButton, DrawerContent, DrawerDescription, DrawerFooter, DrawerHeader, DrawerOverlay, DrawerTitle, DrawerTrigger, DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuLabel, DropdownMenuPortal, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuSeparator, DropdownMenuShortcut, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuTrigger, Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle, EntityIcon, Field, FieldDescription, FieldError, FieldInput, FieldLabel, FieldLegend, FieldSeparator, FieldSet, FieldTextArea, HoverCard, HoverCardContent, HoverCardTrigger, InlineCode, Input, InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput, InputGroupText, InputOtp, InputOtpCaret, Item, ItemDescription, ItemEmpty, ItemGroup, ItemGroupEntry, ItemTitle, Kbd, KbdGroup, Label, ListGroup, ListRow, MAX_LAYOUT_DEPTH, MAX_LAYOUT_LEAVES, MAX_SPLIT_RATIO, MIN_SPLIT_RATIO, Menubar, MenubarCheckboxItem, MenubarContent, MenubarGroup, MenubarItem, MenubarLabel, MenubarMenu, MenubarRadioGroup, MenubarRadioItem, MenubarSeparator, MenubarSub, MenubarSubContent, MenubarSubTrigger, MenubarTrigger, MessageBody, MessageComposer, MessageDayDivider, MessageGroup, MessageRow, ModalDialog, Motion, NavigationMenu, NavigationMenuContent, NavigationMenuGroup, NavigationMenuGroupLabel, NavigationMenuItem, NavigationMenuMenu, NavigationMenuSeparator, NavigationMenuTrigger, NavigationMenuViewport, PASTE_THRESHOLD_CHARS, PASTE_THRESHOLD_LINES, PASTE_TOKEN_REGEX, Page, PageHeader, PageHeaderActions, PageHeaderContent, PageHeaderDescription, PageHeaderTitle, PageSection, Pagination, PaginationEllipsis, PaginationItem, PaginationNext, PaginationPrevious, PalettePreview, Panel, PanelActions, PanelBody, PanelCard, PanelDescription, PanelFooter, PanelHeader, PanelPlaceholder, PanelTitle, PanelToolbar, Popover, PopoverAnchor, PopoverCloseButton, PopoverContent, PopoverDescription, PopoverTitle, PopoverTrigger, Presence, Progress, PropertyList, PropertyRow, PropertyTerm, PropertyValue, RadioGroup, RadioGroupItem, ResizableHandle, ResizablePanel, ResizablePanelGroup, ScrollArea, Select, SelectChevronsUpDown, SelectContent, SelectItem, SelectLabel, SelectSection, SelectTrigger, SelectValue, Separator, SettingsField, SettingsPage, SettingsRow, SettingsSection, Sheet, SheetBody, SheetCloseButton, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle, SheetTrigger, SideRail, SideRailButton, SideRailContent, SideRailFooter, SideRailHeader, SideRailItem, SideRailSection, SidebarNav, SidebarNavButton, SidebarNavContent, SidebarNavFooter, SidebarNavHeader, SidebarNavItem, SidebarNavSection, SidebarNavTitle, Skeleton, Slider, Spinner, SplitLayout, Stat, StatGroup, StatusBar, StatusBarItem, StatusBarSpacer, StatusChip, StatusList, Switch, SwitchDescription, SwitchLabel, TALL_CODE_BLOCK_PX, Table, TableBody, TableCaption, TableCell, TableFooter, TableHead, TableHeader, TableNumericCell, TableRow, Tabs, TabsContent, TabsList, TabsTrigger, Textarea, ThemeMiniature, ThemeMiniatureSplit, ThemePicker, ThemePreview, ThemeProvider, ThemeSwatch, ThemeToggle, ThreadPanel, Toaster, Toggle, ToggleGroup, ToggleGroupItem, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, TopBar, TopBarBreadcrumb, TopBarPill, TopBarSearch, TopBarSection, TopBarTitle, UpdateDialog, WorkspaceMark, accentPresets, alertVariants, allTokens, badgeVariants, boardCardTitleVariants, builtinThemes, buttonVariants, closePane, cn, colorTokens, computeLayoutFrames, contrastRatio, controlInteractive, controlSize, controlSizeIcon, controlSizes, countLeaves, countLines, createLayoutState, cva, defaultDarkThemeId, defaultLightThemeId, defaultThemeSelection, densityTokens, designTokens, diffLineVariants, diffStats, diffTargetPath, elevationTokens, entityIconVariants, expandAll, findTokenRanges, focusPane, fontOptions, formatBytes, formatReleaseDate, formatToken, initialsFrom, inlineScriptLiteral, isPasteBlock, itemVariants, keyedRows, layoutDepth, listLeaves, monogram, motionTokens, movePane, neighborLeaf, nextSeq, normalizeLayout, parseDiff, plainTextFromMarkdown, pruneBlocks, radiusTokens, recollapsePastes, remapCarriedBlocks, resizeSplit, resolvedDensity, sheetVariants, shouldCollapse, sidebarNavItemClass, sidebarNavItemStateClass, splitPane, statusDotVariants, stripTrailingBlankLines, surfaceInteractive, swapPanes, themeById, themeCssVariables, themeFamilies, themeScript, themesForAppearance, toast, toaster, toggleVariants, tokenRangeAt, typographyTokens, undoClosePane, undocumentedTokenAliases, useSideRail, useTheme, validateTheme, validateThemeRegistry, zIndexTokens };
@@ -57,6 +57,11 @@
57
57
  "type": "registry:ui",
58
58
  "target": "components/conversation/message-row.tsx"
59
59
  },
60
+ {
61
+ "path": "src/components/conversation/paste-tokens.ts",
62
+ "type": "registry:ui",
63
+ "target": "components/conversation/paste-tokens.ts"
64
+ },
60
65
  {
61
66
  "path": "src/components/conversation/scroll-follow-core.ts",
62
67
  "type": "registry:ui",
@@ -1871,6 +1871,11 @@
1871
1871
  "type": "registry:ui",
1872
1872
  "target": "components/conversation/message-row.tsx"
1873
1873
  },
1874
+ {
1875
+ "path": "src/components/conversation/paste-tokens.ts",
1876
+ "type": "registry:ui",
1877
+ "target": "components/conversation/paste-tokens.ts"
1878
+ },
1874
1879
  {
1875
1880
  "path": "src/components/conversation/scroll-follow-core.ts",
1876
1881
  "type": "registry:ui",
@@ -25,3 +25,22 @@ export {
25
25
  type AttachmentCardProps,
26
26
  type ThreadPanelProps,
27
27
  } from './thread-panel'
28
+ // Issue #532 selected pure paste-token model.
29
+ export {
30
+ countLines,
31
+ expandAll,
32
+ findTokenRanges,
33
+ formatToken,
34
+ isPasteBlock,
35
+ nextSeq,
36
+ pruneBlocks,
37
+ recollapsePastes,
38
+ remapCarriedBlocks,
39
+ shouldCollapse,
40
+ stripTrailingBlankLines,
41
+ tokenRangeAt,
42
+ PASTE_THRESHOLD_CHARS,
43
+ PASTE_THRESHOLD_LINES,
44
+ PASTE_TOKEN_REGEX,
45
+ type PasteBlock,
46
+ } from './paste-tokens'
@@ -0,0 +1,272 @@
1
+ /**
2
+ * Pure paste-token transformations translated and hardened from KiroCrew's
3
+ * `website/src/utils/pasteTokens.ts` at revision
4
+ * `283e136c0f902e965a535a7c9548c57c7504fed0` (Apache-2.0). See the #532 entry
5
+ * in the repository NOTICE. Paste identity is supplied by the host; this module
6
+ * does not access storage, clipboard APIs, message services, or editor state.
7
+ */
8
+
9
+ /** A collapsed paste block stored alongside input or a message. */
10
+ export interface PasteBlock {
11
+ /** A stable host-provided ASCII identifier; never included in visible token text. */
12
+ id: string
13
+ /** Positive safe integer, unique among blocks in the same text value. */
14
+ seq: number
15
+ /** Positive safe integer displayed in the token. */
16
+ lines: number
17
+ /** Original pasted text, retained verbatim. */
18
+ content: string
19
+ }
20
+
21
+ export const PASTE_THRESHOLD_LINES = 3
22
+ export const PASTE_THRESHOLD_CHARS = 200
23
+
24
+ const PASTE_ID_PATTERN = /^[A-Za-z0-9._:-]{1,128}$/
25
+ const MAX_SAFE_SEQUENCE_TEXT_LENGTH = String(Number.MAX_SAFE_INTEGER).length
26
+ const PASTE_TOKEN_SOURCE = String.raw`\[ Paste #([1-9]\d{0,${MAX_SAFE_SEQUENCE_TEXT_LENGTH - 1}}) · ([1-9]\d{0,${MAX_SAFE_SEQUENCE_TEXT_LENGTH - 1}}) lines \]`
27
+
28
+ /** Canonical token pattern. Internal scans always use a fresh RegExp instance. */
29
+ export const PASTE_TOKEN_REGEX = new RegExp(PASTE_TOKEN_SOURCE, 'g')
30
+
31
+ function isPositiveSafeInteger(value: unknown): value is number {
32
+ return Number.isSafeInteger(value) && (value as number) > 0
33
+ }
34
+
35
+ /** Check data received from a host before using it as a paste-token block. */
36
+ export function isPasteBlock(value: unknown): value is PasteBlock {
37
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) return false
38
+ const candidate = value as Record<string, unknown>
39
+ return (
40
+ typeof candidate.id === 'string' &&
41
+ PASTE_ID_PATTERN.test(candidate.id) &&
42
+ isPositiveSafeInteger(candidate.seq) &&
43
+ isPositiveSafeInteger(candidate.lines) &&
44
+ typeof candidate.content === 'string'
45
+ )
46
+ }
47
+
48
+ function unambiguousBlocks(blocks: readonly PasteBlock[]): PasteBlock[] {
49
+ const valid = blocks.filter(isPasteBlock)
50
+ const seqCounts = new Map<number, number>()
51
+ const idCounts = new Map<string, number>()
52
+ for (const block of valid) {
53
+ seqCounts.set(block.seq, (seqCounts.get(block.seq) ?? 0) + 1)
54
+ idCounts.set(block.id, (idCounts.get(block.id) ?? 0) + 1)
55
+ }
56
+ return valid.filter((block) => seqCounts.get(block.seq) === 1 && idCounts.get(block.id) === 1)
57
+ }
58
+
59
+ export function formatToken(block: PasteBlock): string {
60
+ if (!isPasteBlock(block)) throw new TypeError('Cannot format an invalid paste block')
61
+ return `[ Paste #${block.seq} · ${block.lines} lines ]`
62
+ }
63
+
64
+ export function shouldCollapse(text: string): boolean {
65
+ if (!text) return false
66
+ return countLines(text) >= PASTE_THRESHOLD_LINES || text.length >= PASTE_THRESHOLD_CHARS
67
+ }
68
+
69
+ export function countLines(text: string): number {
70
+ if (!text) return 0
71
+ return text.split('\n').length
72
+ }
73
+
74
+ /** Next sequence for a new paste = max existing + 1, starting at 1. */
75
+ export function nextSeq(blocks: readonly PasteBlock[]): number {
76
+ let max = 0
77
+ for (const block of blocks) {
78
+ if (isPasteBlock(block) && block.seq > max) max = block.seq
79
+ }
80
+ if (max === Number.MAX_SAFE_INTEGER) throw new RangeError('Paste sequence space is exhausted')
81
+ return max + 1
82
+ }
83
+
84
+ /**
85
+ * Re-sequence `carried` blocks whose sequence is already taken by `used`, and
86
+ * rewrite their markers in one right-to-left pass. Carried block IDs and
87
+ * sequences must be unique so a visible marker always has one backing block.
88
+ * `used` is mutated to include the assigned sequences.
89
+ */
90
+ export function remapCarriedBlocks(
91
+ text: string,
92
+ carried: readonly PasteBlock[],
93
+ used: Set<number>
94
+ ): { text: string; blocks: PasteBlock[] } {
95
+ const seenSeqs = new Set<number>()
96
+ const seenIds = new Set<string>()
97
+ for (const block of carried) {
98
+ if (!isPasteBlock(block)) throw new TypeError('Cannot remap an invalid paste block')
99
+ if (seenSeqs.has(block.seq) || seenIds.has(block.id)) {
100
+ throw new TypeError('Carried paste block IDs and sequences must be unique')
101
+ }
102
+ seenSeqs.add(block.seq)
103
+ seenIds.add(block.id)
104
+ }
105
+
106
+ let maxUsed = 0
107
+ for (const value of used) {
108
+ if (isPositiveSafeInteger(value) && value > maxUsed) maxUsed = value
109
+ }
110
+ let free = maxUsed < Number.MAX_SAFE_INTEGER ? maxUsed + 1 : 1
111
+ const allocateFree = (): number => {
112
+ while (used.has(free)) {
113
+ free = free < Number.MAX_SAFE_INTEGER ? free + 1 : 1
114
+ }
115
+ const assigned = free
116
+ used.add(assigned)
117
+ free = assigned < Number.MAX_SAFE_INTEGER ? assigned + 1 : 1
118
+ return assigned
119
+ }
120
+
121
+ const remap = new Map<number, number>()
122
+ const blocks = carried.map((block) => {
123
+ if (!used.has(block.seq)) {
124
+ used.add(block.seq)
125
+ return block
126
+ }
127
+ const seq = allocateFree()
128
+ remap.set(block.seq, seq)
129
+ return { ...block, seq }
130
+ })
131
+
132
+ if (!remap.size) return { text, blocks }
133
+
134
+ let out = text
135
+ const ranges = findTokenRanges(text, carried)
136
+ for (let index = ranges.length - 1; index >= 0; index -= 1) {
137
+ const { start, end, block } = ranges[index]!
138
+ const mapped = remap.get(block.seq)
139
+ if (mapped === undefined) continue
140
+ out = out.slice(0, start) + formatToken({ ...block, seq: mapped }) + out.slice(end)
141
+ }
142
+ return { text: out, blocks }
143
+ }
144
+
145
+ /** Ranges for canonical tokens whose sequence and line count match one block. */
146
+ export function findTokenRanges(
147
+ text: string,
148
+ blocks: readonly PasteBlock[]
149
+ ): Array<{ start: number; end: number; block: PasteBlock }> {
150
+ if (!text || !blocks.length) return []
151
+ const bySeq = new Map(unambiguousBlocks(blocks).map((block) => [block.seq, block]))
152
+ if (!bySeq.size) return []
153
+
154
+ const tokenRegex = new RegExp(PASTE_TOKEN_SOURCE, 'g')
155
+ const ranges: Array<{ start: number; end: number; block: PasteBlock }> = []
156
+ let match: RegExpExecArray | null
157
+ while ((match = tokenRegex.exec(text)) !== null) {
158
+ const seq = Number(match[1])
159
+ const lines = Number(match[2])
160
+ if (!isPositiveSafeInteger(seq) || !isPositiveSafeInteger(lines)) continue
161
+ const block = bySeq.get(seq)
162
+ if (block && block.lines === lines) {
163
+ ranges.push({ start: match.index, end: match.index + match[0].length, block })
164
+ }
165
+ }
166
+ return ranges
167
+ }
168
+
169
+ export function tokenRangeAt(
170
+ text: string,
171
+ blocks: readonly PasteBlock[],
172
+ caret: number
173
+ ): { start: number; end: number; block: PasteBlock } | null {
174
+ if (!Number.isSafeInteger(caret) || caret < 0 || caret > text.length) return null
175
+ for (const range of findTokenRanges(text, blocks)) {
176
+ if (caret >= range.start && caret <= range.end) return range
177
+ }
178
+ return null
179
+ }
180
+
181
+ export function pruneBlocks(text: string, blocks: PasteBlock[]): PasteBlock[] {
182
+ if (!blocks.length) return blocks
183
+ const usable = unambiguousBlocks(blocks)
184
+ const survivors = new Set(findTokenRanges(text, usable).map((range) => range.block.id))
185
+ const next = usable.filter((block) => survivors.has(block.id))
186
+ return next.length === blocks.length && next.every((block, index) => block === blocks[index])
187
+ ? blocks
188
+ : next
189
+ }
190
+
191
+ export function expandAll(text: string, blocks: readonly PasteBlock[]): string {
192
+ if (!text || !blocks.length) return text
193
+ const ranges = findTokenRanges(text, blocks)
194
+ if (!ranges.length) return text
195
+ let out = text
196
+ for (let index = ranges.length - 1; index >= 0; index -= 1) {
197
+ const range = ranges[index]!
198
+ out = out.slice(0, range.start) + range.block.content + out.slice(range.end)
199
+ }
200
+ return out
201
+ }
202
+
203
+ /** Inverse of `expandAll`: replace each first non-overlapping verbatim block. */
204
+ export function recollapsePastes(content: string, blocks: readonly PasteBlock[]): string {
205
+ if (!content || !blocks.length) return content
206
+
207
+ interface Hit {
208
+ start: number
209
+ end: number
210
+ block: PasteBlock
211
+ }
212
+ const hits: Hit[] = []
213
+ const claimed: Array<[number, number]> = []
214
+ const firstUnclaimed = (needle: string): number => {
215
+ if (!needle) return -1
216
+ let from = 0
217
+ while (from <= content.length) {
218
+ const index = content.indexOf(needle, from)
219
+ if (index < 0) return -1
220
+ if (!claimed.some(([start, end]) => index < end && index + needle.length > start))
221
+ return index
222
+ from = index + 1
223
+ }
224
+ return -1
225
+ }
226
+
227
+ for (const block of unambiguousBlocks(blocks)) {
228
+ if (!block.content) continue
229
+ const trimmed = block.content.trimEnd()
230
+ let needle = block.content
231
+ let index = firstUnclaimed(needle)
232
+ if (index < 0 && trimmed && trimmed !== block.content) {
233
+ needle = trimmed
234
+ index = firstUnclaimed(needle)
235
+ }
236
+ if (index < 0) continue
237
+ const end = index + needle.length
238
+ hits.push({ start: index, end, block })
239
+ claimed.push([index, end])
240
+ }
241
+
242
+ if (!hits.length) return content
243
+ hits.sort((left, right) => left.start - right.start)
244
+ let out = ''
245
+ let position = 0
246
+ for (const hit of hits) {
247
+ if (hit.start < position) continue
248
+ out += content.slice(position, hit.start) + formatToken(hit.block)
249
+ position = hit.end
250
+ }
251
+ return out + content.slice(position)
252
+ }
253
+
254
+ /** Strip a trailing whitespace run only when that run contains a newline. */
255
+ export function stripTrailingBlankLines(value: string): string {
256
+ let index = value.length - 1
257
+ let sawNewline = false
258
+ while (index >= 0) {
259
+ const code = value.charCodeAt(index)
260
+ if (code === 10 || code === 13) {
261
+ sawNewline = true
262
+ index -= 1
263
+ continue
264
+ }
265
+ if (code === 32 || code === 9) {
266
+ index -= 1
267
+ continue
268
+ }
269
+ break
270
+ }
271
+ return sawNewline ? value.slice(0, index + 1) : value
272
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adea-ai/ui",
3
- "version": "0.66.1",
3
+ "version": "0.67.0",
4
4
  "description": "The Adea design system: Kobalte-backed Solid components, semantic OKLCH tokens, and the app-shell layout primitives shared by Adea and Cortana.",
5
5
  "keywords": [
6
6
  "adea",
@@ -70,6 +70,12 @@
70
70
  "solid": "./src/components/layout/split-layout/model.ts",
71
71
  "development": "./src/components/layout/split-layout/model.ts",
72
72
  "import": "./dist/components/layout/split-layout/model.js"
73
+ },
74
+ "./components/conversation/paste-tokens": {
75
+ "types": "./dist/components/conversation/paste-tokens.d.ts",
76
+ "solid": "./src/components/conversation/paste-tokens.ts",
77
+ "development": "./src/components/conversation/paste-tokens.ts",
78
+ "import": "./dist/components/conversation/paste-tokens.js"
73
79
  }
74
80
  },
75
81
  "scripts": {
@@ -88,7 +94,8 @@
88
94
  "check:packed-consumer": "bun scripts/check-packed-consumer.ts",
89
95
  "check:packed-conversation": "bun scripts/check-packed-conversation.ts",
90
96
  "check:packed-layout-renderer": "bun scripts/check-packed-layout-renderer.ts",
91
- "check:packed-appearance": "bun scripts/check-packed-appearance.ts"
97
+ "check:packed-appearance": "bun scripts/check-packed-appearance.ts",
98
+ "check:packed-paste-model": "node scripts/check-packed-paste-model.mjs"
92
99
  },
93
100
  "dependencies": {
94
101
  "@adea-ai/themes": "^0.5.0",
package/registry.json CHANGED
@@ -1871,6 +1871,11 @@
1871
1871
  "type": "registry:ui",
1872
1872
  "target": "components/conversation/message-row.tsx"
1873
1873
  },
1874
+ {
1875
+ "path": "src/components/conversation/paste-tokens.ts",
1876
+ "type": "registry:ui",
1877
+ "target": "components/conversation/paste-tokens.ts"
1878
+ },
1874
1879
  {
1875
1880
  "path": "src/components/conversation/scroll-follow-core.ts",
1876
1881
  "type": "registry:ui",
@@ -25,3 +25,22 @@ export {
25
25
  type AttachmentCardProps,
26
26
  type ThreadPanelProps,
27
27
  } from './thread-panel'
28
+ // Issue #532 selected pure paste-token model.
29
+ export {
30
+ countLines,
31
+ expandAll,
32
+ findTokenRanges,
33
+ formatToken,
34
+ isPasteBlock,
35
+ nextSeq,
36
+ pruneBlocks,
37
+ recollapsePastes,
38
+ remapCarriedBlocks,
39
+ shouldCollapse,
40
+ stripTrailingBlankLines,
41
+ tokenRangeAt,
42
+ PASTE_THRESHOLD_CHARS,
43
+ PASTE_THRESHOLD_LINES,
44
+ PASTE_TOKEN_REGEX,
45
+ type PasteBlock,
46
+ } from './paste-tokens'
@@ -0,0 +1,272 @@
1
+ /**
2
+ * Pure paste-token transformations translated and hardened from KiroCrew's
3
+ * `website/src/utils/pasteTokens.ts` at revision
4
+ * `283e136c0f902e965a535a7c9548c57c7504fed0` (Apache-2.0). See the #532 entry
5
+ * in the repository NOTICE. Paste identity is supplied by the host; this module
6
+ * does not access storage, clipboard APIs, message services, or editor state.
7
+ */
8
+
9
+ /** A collapsed paste block stored alongside input or a message. */
10
+ export interface PasteBlock {
11
+ /** A stable host-provided ASCII identifier; never included in visible token text. */
12
+ id: string
13
+ /** Positive safe integer, unique among blocks in the same text value. */
14
+ seq: number
15
+ /** Positive safe integer displayed in the token. */
16
+ lines: number
17
+ /** Original pasted text, retained verbatim. */
18
+ content: string
19
+ }
20
+
21
+ export const PASTE_THRESHOLD_LINES = 3
22
+ export const PASTE_THRESHOLD_CHARS = 200
23
+
24
+ const PASTE_ID_PATTERN = /^[A-Za-z0-9._:-]{1,128}$/
25
+ const MAX_SAFE_SEQUENCE_TEXT_LENGTH = String(Number.MAX_SAFE_INTEGER).length
26
+ const PASTE_TOKEN_SOURCE = String.raw`\[ Paste #([1-9]\d{0,${MAX_SAFE_SEQUENCE_TEXT_LENGTH - 1}}) · ([1-9]\d{0,${MAX_SAFE_SEQUENCE_TEXT_LENGTH - 1}}) lines \]`
27
+
28
+ /** Canonical token pattern. Internal scans always use a fresh RegExp instance. */
29
+ export const PASTE_TOKEN_REGEX = new RegExp(PASTE_TOKEN_SOURCE, 'g')
30
+
31
+ function isPositiveSafeInteger(value: unknown): value is number {
32
+ return Number.isSafeInteger(value) && (value as number) > 0
33
+ }
34
+
35
+ /** Check data received from a host before using it as a paste-token block. */
36
+ export function isPasteBlock(value: unknown): value is PasteBlock {
37
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) return false
38
+ const candidate = value as Record<string, unknown>
39
+ return (
40
+ typeof candidate.id === 'string' &&
41
+ PASTE_ID_PATTERN.test(candidate.id) &&
42
+ isPositiveSafeInteger(candidate.seq) &&
43
+ isPositiveSafeInteger(candidate.lines) &&
44
+ typeof candidate.content === 'string'
45
+ )
46
+ }
47
+
48
+ function unambiguousBlocks(blocks: readonly PasteBlock[]): PasteBlock[] {
49
+ const valid = blocks.filter(isPasteBlock)
50
+ const seqCounts = new Map<number, number>()
51
+ const idCounts = new Map<string, number>()
52
+ for (const block of valid) {
53
+ seqCounts.set(block.seq, (seqCounts.get(block.seq) ?? 0) + 1)
54
+ idCounts.set(block.id, (idCounts.get(block.id) ?? 0) + 1)
55
+ }
56
+ return valid.filter((block) => seqCounts.get(block.seq) === 1 && idCounts.get(block.id) === 1)
57
+ }
58
+
59
+ export function formatToken(block: PasteBlock): string {
60
+ if (!isPasteBlock(block)) throw new TypeError('Cannot format an invalid paste block')
61
+ return `[ Paste #${block.seq} · ${block.lines} lines ]`
62
+ }
63
+
64
+ export function shouldCollapse(text: string): boolean {
65
+ if (!text) return false
66
+ return countLines(text) >= PASTE_THRESHOLD_LINES || text.length >= PASTE_THRESHOLD_CHARS
67
+ }
68
+
69
+ export function countLines(text: string): number {
70
+ if (!text) return 0
71
+ return text.split('\n').length
72
+ }
73
+
74
+ /** Next sequence for a new paste = max existing + 1, starting at 1. */
75
+ export function nextSeq(blocks: readonly PasteBlock[]): number {
76
+ let max = 0
77
+ for (const block of blocks) {
78
+ if (isPasteBlock(block) && block.seq > max) max = block.seq
79
+ }
80
+ if (max === Number.MAX_SAFE_INTEGER) throw new RangeError('Paste sequence space is exhausted')
81
+ return max + 1
82
+ }
83
+
84
+ /**
85
+ * Re-sequence `carried` blocks whose sequence is already taken by `used`, and
86
+ * rewrite their markers in one right-to-left pass. Carried block IDs and
87
+ * sequences must be unique so a visible marker always has one backing block.
88
+ * `used` is mutated to include the assigned sequences.
89
+ */
90
+ export function remapCarriedBlocks(
91
+ text: string,
92
+ carried: readonly PasteBlock[],
93
+ used: Set<number>
94
+ ): { text: string; blocks: PasteBlock[] } {
95
+ const seenSeqs = new Set<number>()
96
+ const seenIds = new Set<string>()
97
+ for (const block of carried) {
98
+ if (!isPasteBlock(block)) throw new TypeError('Cannot remap an invalid paste block')
99
+ if (seenSeqs.has(block.seq) || seenIds.has(block.id)) {
100
+ throw new TypeError('Carried paste block IDs and sequences must be unique')
101
+ }
102
+ seenSeqs.add(block.seq)
103
+ seenIds.add(block.id)
104
+ }
105
+
106
+ let maxUsed = 0
107
+ for (const value of used) {
108
+ if (isPositiveSafeInteger(value) && value > maxUsed) maxUsed = value
109
+ }
110
+ let free = maxUsed < Number.MAX_SAFE_INTEGER ? maxUsed + 1 : 1
111
+ const allocateFree = (): number => {
112
+ while (used.has(free)) {
113
+ free = free < Number.MAX_SAFE_INTEGER ? free + 1 : 1
114
+ }
115
+ const assigned = free
116
+ used.add(assigned)
117
+ free = assigned < Number.MAX_SAFE_INTEGER ? assigned + 1 : 1
118
+ return assigned
119
+ }
120
+
121
+ const remap = new Map<number, number>()
122
+ const blocks = carried.map((block) => {
123
+ if (!used.has(block.seq)) {
124
+ used.add(block.seq)
125
+ return block
126
+ }
127
+ const seq = allocateFree()
128
+ remap.set(block.seq, seq)
129
+ return { ...block, seq }
130
+ })
131
+
132
+ if (!remap.size) return { text, blocks }
133
+
134
+ let out = text
135
+ const ranges = findTokenRanges(text, carried)
136
+ for (let index = ranges.length - 1; index >= 0; index -= 1) {
137
+ const { start, end, block } = ranges[index]!
138
+ const mapped = remap.get(block.seq)
139
+ if (mapped === undefined) continue
140
+ out = out.slice(0, start) + formatToken({ ...block, seq: mapped }) + out.slice(end)
141
+ }
142
+ return { text: out, blocks }
143
+ }
144
+
145
+ /** Ranges for canonical tokens whose sequence and line count match one block. */
146
+ export function findTokenRanges(
147
+ text: string,
148
+ blocks: readonly PasteBlock[]
149
+ ): Array<{ start: number; end: number; block: PasteBlock }> {
150
+ if (!text || !blocks.length) return []
151
+ const bySeq = new Map(unambiguousBlocks(blocks).map((block) => [block.seq, block]))
152
+ if (!bySeq.size) return []
153
+
154
+ const tokenRegex = new RegExp(PASTE_TOKEN_SOURCE, 'g')
155
+ const ranges: Array<{ start: number; end: number; block: PasteBlock }> = []
156
+ let match: RegExpExecArray | null
157
+ while ((match = tokenRegex.exec(text)) !== null) {
158
+ const seq = Number(match[1])
159
+ const lines = Number(match[2])
160
+ if (!isPositiveSafeInteger(seq) || !isPositiveSafeInteger(lines)) continue
161
+ const block = bySeq.get(seq)
162
+ if (block && block.lines === lines) {
163
+ ranges.push({ start: match.index, end: match.index + match[0].length, block })
164
+ }
165
+ }
166
+ return ranges
167
+ }
168
+
169
+ export function tokenRangeAt(
170
+ text: string,
171
+ blocks: readonly PasteBlock[],
172
+ caret: number
173
+ ): { start: number; end: number; block: PasteBlock } | null {
174
+ if (!Number.isSafeInteger(caret) || caret < 0 || caret > text.length) return null
175
+ for (const range of findTokenRanges(text, blocks)) {
176
+ if (caret >= range.start && caret <= range.end) return range
177
+ }
178
+ return null
179
+ }
180
+
181
+ export function pruneBlocks(text: string, blocks: PasteBlock[]): PasteBlock[] {
182
+ if (!blocks.length) return blocks
183
+ const usable = unambiguousBlocks(blocks)
184
+ const survivors = new Set(findTokenRanges(text, usable).map((range) => range.block.id))
185
+ const next = usable.filter((block) => survivors.has(block.id))
186
+ return next.length === blocks.length && next.every((block, index) => block === blocks[index])
187
+ ? blocks
188
+ : next
189
+ }
190
+
191
+ export function expandAll(text: string, blocks: readonly PasteBlock[]): string {
192
+ if (!text || !blocks.length) return text
193
+ const ranges = findTokenRanges(text, blocks)
194
+ if (!ranges.length) return text
195
+ let out = text
196
+ for (let index = ranges.length - 1; index >= 0; index -= 1) {
197
+ const range = ranges[index]!
198
+ out = out.slice(0, range.start) + range.block.content + out.slice(range.end)
199
+ }
200
+ return out
201
+ }
202
+
203
+ /** Inverse of `expandAll`: replace each first non-overlapping verbatim block. */
204
+ export function recollapsePastes(content: string, blocks: readonly PasteBlock[]): string {
205
+ if (!content || !blocks.length) return content
206
+
207
+ interface Hit {
208
+ start: number
209
+ end: number
210
+ block: PasteBlock
211
+ }
212
+ const hits: Hit[] = []
213
+ const claimed: Array<[number, number]> = []
214
+ const firstUnclaimed = (needle: string): number => {
215
+ if (!needle) return -1
216
+ let from = 0
217
+ while (from <= content.length) {
218
+ const index = content.indexOf(needle, from)
219
+ if (index < 0) return -1
220
+ if (!claimed.some(([start, end]) => index < end && index + needle.length > start))
221
+ return index
222
+ from = index + 1
223
+ }
224
+ return -1
225
+ }
226
+
227
+ for (const block of unambiguousBlocks(blocks)) {
228
+ if (!block.content) continue
229
+ const trimmed = block.content.trimEnd()
230
+ let needle = block.content
231
+ let index = firstUnclaimed(needle)
232
+ if (index < 0 && trimmed && trimmed !== block.content) {
233
+ needle = trimmed
234
+ index = firstUnclaimed(needle)
235
+ }
236
+ if (index < 0) continue
237
+ const end = index + needle.length
238
+ hits.push({ start: index, end, block })
239
+ claimed.push([index, end])
240
+ }
241
+
242
+ if (!hits.length) return content
243
+ hits.sort((left, right) => left.start - right.start)
244
+ let out = ''
245
+ let position = 0
246
+ for (const hit of hits) {
247
+ if (hit.start < position) continue
248
+ out += content.slice(position, hit.start) + formatToken(hit.block)
249
+ position = hit.end
250
+ }
251
+ return out + content.slice(position)
252
+ }
253
+
254
+ /** Strip a trailing whitespace run only when that run contains a newline. */
255
+ export function stripTrailingBlankLines(value: string): string {
256
+ let index = value.length - 1
257
+ let sawNewline = false
258
+ while (index >= 0) {
259
+ const code = value.charCodeAt(index)
260
+ if (code === 10 || code === 13) {
261
+ sawNewline = true
262
+ index -= 1
263
+ continue
264
+ }
265
+ if (code === 32 || code === 9) {
266
+ index -= 1
267
+ continue
268
+ }
269
+ break
270
+ }
271
+ return sawNewline ? value.slice(0, index + 1) : value
272
+ }