@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.
@@ -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
+ }
@@ -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.66.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
  }}