@adea-ai/ui 0.66.0 → 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 +26 -0
- package/dist/NOTICE +24 -0
- package/dist/components/conversation/index.d.ts +1 -0
- package/dist/components/conversation/index.d.ts.map +1 -1
- package/dist/components/conversation/index.js +2 -1
- package/dist/components/conversation/paste-tokens.d.ts +57 -0
- package/dist/components/conversation/paste-tokens.d.ts.map +1 -0
- package/dist/components/conversation/paste-tokens.js +216 -0
- package/dist/components/conversation/paste-tokens.js.map +1 -0
- package/dist/components/layout/split-layout/split-layout.d.ts.map +1 -1
- package/dist/components/layout/split-layout/split-layout.js +2 -0
- package/dist/components/layout/split-layout/split-layout.js.map +1 -1
- package/dist/index.js +2 -1
- package/dist/r/conversation.json +5 -0
- package/dist/r/registry.json +5 -0
- package/dist/r/src/components/conversation/index.ts +19 -0
- package/dist/r/src/components/conversation/paste-tokens.ts +272 -0
- package/dist/r/src/components/layout/split-layout/split-layout.tsx +10 -0
- package/package.json +9 -2
- package/registry.json +5 -0
- package/src/components/conversation/index.ts +19 -0
- package/src/components/conversation/paste-tokens.ts +272 -0
- package/src/components/layout/split-layout/split-layout.tsx +10 -0
package/dist/r/conversation.json
CHANGED
|
@@ -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",
|
package/dist/r/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
|
+
}
|
|
@@ -307,6 +307,16 @@ export function SplitLayout<L extends SplitLayoutLeaf>(props: SplitLayoutProps<L
|
|
|
307
307
|
sizes={[branch().ratio, 1 - branch().ratio]}
|
|
308
308
|
keyboardDelta={0.05}
|
|
309
309
|
onSizesChange={(sizes) => {
|
|
310
|
+
// Corvu also reports panel registration/unregistration while
|
|
311
|
+
// mounting or disposing a controller. Those incomplete or
|
|
312
|
+
// hidden-host sizes are not user resize intent.
|
|
313
|
+
if (!root?.isConnected || root.getClientRects().length === 0) return
|
|
314
|
+
if (
|
|
315
|
+
sizes.length !== 2 ||
|
|
316
|
+
sizes.some((size) => !Number.isFinite(size)) ||
|
|
317
|
+
Math.abs(sizes[0]! + sizes[1]! - 1) > 0.000001
|
|
318
|
+
)
|
|
319
|
+
return
|
|
310
320
|
if (sizes[0] !== undefined && Math.abs(sizes[0] - branch().ratio) > 0.000001)
|
|
311
321
|
props.onResize(id, sizes[0])
|
|
312
322
|
}}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@adea-ai/ui",
|
|
3
|
-
"version": "0.
|
|
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
|
+
}
|
|
@@ -307,6 +307,16 @@ export function SplitLayout<L extends SplitLayoutLeaf>(props: SplitLayoutProps<L
|
|
|
307
307
|
sizes={[branch().ratio, 1 - branch().ratio]}
|
|
308
308
|
keyboardDelta={0.05}
|
|
309
309
|
onSizesChange={(sizes) => {
|
|
310
|
+
// Corvu also reports panel registration/unregistration while
|
|
311
|
+
// mounting or disposing a controller. Those incomplete or
|
|
312
|
+
// hidden-host sizes are not user resize intent.
|
|
313
|
+
if (!root?.isConnected || root.getClientRects().length === 0) return
|
|
314
|
+
if (
|
|
315
|
+
sizes.length !== 2 ||
|
|
316
|
+
sizes.some((size) => !Number.isFinite(size)) ||
|
|
317
|
+
Math.abs(sizes[0]! + sizes[1]! - 1) > 0.000001
|
|
318
|
+
)
|
|
319
|
+
return
|
|
310
320
|
if (sizes[0] !== undefined && Math.abs(sizes[0] - branch().ratio) > 0.000001)
|
|
311
321
|
props.onResize(id, sizes[0])
|
|
312
322
|
}}
|