@workerdeck/ui 0.6.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.
Files changed (61) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +74 -0
  3. package/build/index.d.mts +927 -0
  4. package/build/index.mjs +5236 -0
  5. package/build/index.mjs.map +1 -0
  6. package/package.json +73 -0
  7. package/src/components/agent/Composer.tsx +139 -0
  8. package/src/components/agent/Conversation.tsx +59 -0
  9. package/src/components/agent/FileCard.tsx +50 -0
  10. package/src/components/agent/Loader.tsx +21 -0
  11. package/src/components/agent/Message.tsx +44 -0
  12. package/src/components/agent/ModelSelect.tsx +87 -0
  13. package/src/components/agent/PermissionModeSelect.tsx +94 -0
  14. package/src/components/agent/PermissionPrompt.tsx +52 -0
  15. package/src/components/agent/QuestionPrompt.tsx +193 -0
  16. package/src/components/agent/Reasoning.tsx +58 -0
  17. package/src/components/agent/Response.tsx +31 -0
  18. package/src/components/agent/SessionList.tsx +93 -0
  19. package/src/components/agent/SessionPanel.tsx +149 -0
  20. package/src/components/agent/StatusBar.tsx +140 -0
  21. package/src/components/agent/ToolCallCard.tsx +94 -0
  22. package/src/components/agent/Transcript.tsx +114 -0
  23. package/src/components/agent/status.ts +16 -0
  24. package/src/components/prompt-area/animated-placeholder.tsx +42 -0
  25. package/src/components/prompt-area/clipboard-helpers.ts +206 -0
  26. package/src/components/prompt-area/cursor-helpers.ts +244 -0
  27. package/src/components/prompt-area/dom-helpers.ts +721 -0
  28. package/src/components/prompt-area/file-strip.tsx +250 -0
  29. package/src/components/prompt-area/html-to-markdown.ts +278 -0
  30. package/src/components/prompt-area/image-strip.tsx +49 -0
  31. package/src/components/prompt-area/index.ts +23 -0
  32. package/src/components/prompt-area/prompt-area-engine.ts +705 -0
  33. package/src/components/prompt-area/prompt-area-list-ops.ts +499 -0
  34. package/src/components/prompt-area/prompt-area.tsx +375 -0
  35. package/src/components/prompt-area/remove-button.tsx +37 -0
  36. package/src/components/prompt-area/segment-helpers.ts +62 -0
  37. package/src/components/prompt-area/trigger-popover.tsx +139 -0
  38. package/src/components/prompt-area/trigger-presets.ts +143 -0
  39. package/src/components/prompt-area/types.ts +360 -0
  40. package/src/components/prompt-area/use-markdown-mode.ts +113 -0
  41. package/src/components/prompt-area/use-prompt-area-events.ts +470 -0
  42. package/src/components/prompt-area/use-prompt-area-state.ts +131 -0
  43. package/src/components/prompt-area/use-prompt-area.ts +1507 -0
  44. package/src/components/prompt-area/use-trigger-search.ts +115 -0
  45. package/src/components/ui/AlertDialog.tsx +56 -0
  46. package/src/components/ui/Badge.tsx +42 -0
  47. package/src/components/ui/Button.tsx +47 -0
  48. package/src/components/ui/Card.tsx +29 -0
  49. package/src/components/ui/CodeBlock.tsx +31 -0
  50. package/src/components/ui/CopyButton.tsx +28 -0
  51. package/src/components/ui/Input.tsx +20 -0
  52. package/src/components/ui/ProgressRing.tsx +49 -0
  53. package/src/components/ui/Select.tsx +80 -0
  54. package/src/components/ui/Sonner.tsx +22 -0
  55. package/src/components/ui/Spinner.tsx +6 -0
  56. package/src/components/ui/Textarea.tsx +21 -0
  57. package/src/components/ui/Tooltip.tsx +34 -0
  58. package/src/index.ts +99 -0
  59. package/src/lib/format.ts +67 -0
  60. package/src/lib/utils.ts +33 -0
  61. package/src/styles/theme.css +413 -0
@@ -0,0 +1,721 @@
1
+ /**
2
+ * Type-safe DOM helper functions for PromptArea.
3
+ *
4
+ * These replace all `as` type assertions with proper type guards,
5
+ * following the codebase rule: "Never use `any` or `as` assertions."
6
+ */
7
+
8
+ import type { ChipSegment } from './types.ts'
9
+
10
+ // ---------------------------------------------------------------------------
11
+ // Type Guards
12
+ // ---------------------------------------------------------------------------
13
+
14
+ /**
15
+ * Type guard: checks if a DOM node is an HTMLElement.
16
+ */
17
+ export function isHTMLElement(node: Node): node is HTMLElement {
18
+ return node instanceof HTMLElement
19
+ }
20
+
21
+ /**
22
+ * Type guard: checks if a DOM node is a chip element
23
+ * (an HTMLElement with data-chip-trigger attribute).
24
+ */
25
+ export function isChipElement(node: Node): node is HTMLElement {
26
+ return node instanceof HTMLElement && node.dataset.chipTrigger !== undefined
27
+ }
28
+
29
+ /**
30
+ * Type guard: checks if a DOM node is a BR element.
31
+ */
32
+ export function isBRElement(node: Node): node is HTMLBRElement {
33
+ return node instanceof HTMLBRElement
34
+ }
35
+
36
+ /**
37
+ * Type guard: checks if a DOM node is a Text node.
38
+ */
39
+ export function isTextNode(node: Node): node is Text {
40
+ return node instanceof Text
41
+ }
42
+
43
+ /**
44
+ * Checks whether a chip element was auto-resolved (created by pressing space
45
+ * on resolveOnSpace triggers, rather than explicit dropdown selection).
46
+ */
47
+ export function getChipAutoResolved(node: Node): boolean {
48
+ return isChipElement(node) && node.dataset.chipAutoResolved === 'true'
49
+ }
50
+
51
+ /**
52
+ * Type guard: checks if a DOM node is a URL link element
53
+ * (an HTMLAnchorElement with data-url attribute).
54
+ */
55
+ export function isLinkElement(node: Node): node is HTMLAnchorElement {
56
+ return node instanceof HTMLAnchorElement && node.dataset.url === 'true'
57
+ }
58
+
59
+ // ---------------------------------------------------------------------------
60
+ // Safe JSON
61
+ // ---------------------------------------------------------------------------
62
+
63
+ /**
64
+ * Safely parses a JSON string, returning `unknown` instead of `any`.
65
+ * Returns `undefined` if parsing fails.
66
+ */
67
+ export function safeJsonParse(json: string): unknown {
68
+ try {
69
+ // JSON.parse returns `any` by default. We narrow it to `unknown`
70
+ // which is the safest pattern — callers must validate before use.
71
+ const parsed: unknown = JSON.parse(json)
72
+ return parsed
73
+ } catch {
74
+ return undefined
75
+ }
76
+ }
77
+
78
+ /**
79
+ * Safely serializes a value to JSON, returning undefined on failure.
80
+ */
81
+ export function safeJsonStringify(value: unknown): string | undefined {
82
+ try {
83
+ return JSON.stringify(value)
84
+ } catch {
85
+ return undefined
86
+ }
87
+ }
88
+
89
+ // ---------------------------------------------------------------------------
90
+ // DOM reading helpers
91
+ // ---------------------------------------------------------------------------
92
+
93
+ /**
94
+ * Reads the chip trigger character from a chip element's dataset.
95
+ * Returns undefined if the node is not a chip element.
96
+ */
97
+ export function getChipTrigger(node: Node): string | undefined {
98
+ if (!isChipElement(node)) return undefined
99
+ return node.dataset.chipTrigger
100
+ }
101
+
102
+ /**
103
+ * Reads the chip value from a chip element's dataset.
104
+ */
105
+ export function getChipValue(node: Node): string | undefined {
106
+ if (!isChipElement(node)) return undefined
107
+ return node.dataset.chipValue
108
+ }
109
+
110
+ /**
111
+ * Reads the chip display text from a chip element's dataset.
112
+ */
113
+ export function getChipDisplay(node: Node): string | undefined {
114
+ if (!isChipElement(node)) return undefined
115
+ return node.dataset.chipDisplay ?? node.textContent ?? undefined
116
+ }
117
+
118
+ /**
119
+ * Reads and safely parses the chip data from a chip element's dataset.
120
+ */
121
+ export function getChipData(node: Node): unknown {
122
+ if (!isChipElement(node)) return undefined
123
+ const raw = node.dataset.chipData
124
+ if (!raw) return undefined
125
+ return safeJsonParse(raw)
126
+ }
127
+
128
+ /**
129
+ * Length of a chip's plain-text representation (`trigger + displayText`).
130
+ *
131
+ * This is the single definition used wherever DOM offsets are mapped to the
132
+ * plain-text model (cursor mapping, selection sizing). The fallbacks mirror
133
+ * how chips are rendered: `chipDisplay` is always set, but we degrade to
134
+ * `textContent` for resilience against externally-mutated nodes.
135
+ */
136
+ export function chipNodeTextLength(node: HTMLElement): number {
137
+ const trigger = node.dataset.chipTrigger ?? ''
138
+ const display = node.dataset.chipDisplay ?? node.textContent ?? ''
139
+ return trigger.length + display.length
140
+ }
141
+
142
+ /**
143
+ * Reads a chip element into a `ChipSegment`, mirroring how chips are written
144
+ * in `renderSegmentsToDOM`. Returns null when the node is not a chip or is
145
+ * missing a required attribute (trigger, value, or display text).
146
+ *
147
+ * This is the single chip reader shared by the DOM->model sync, chip-click
148
+ * delegation, and clipboard serialization, so they cannot diverge on which
149
+ * fields are required or how optional `data` / `autoResolved` are attached.
150
+ */
151
+ export function chipNodeToSegment(node: Node): ChipSegment | null {
152
+ if (!isChipElement(node)) return null
153
+
154
+ const trigger = getChipTrigger(node)
155
+ const value = getChipValue(node)
156
+ const displayText = getChipDisplay(node)
157
+ if (!trigger || value === undefined || !displayText) return null
158
+
159
+ const data = getChipData(node)
160
+ const autoResolved = getChipAutoResolved(node)
161
+
162
+ return {
163
+ type: 'chip',
164
+ trigger,
165
+ value,
166
+ displayText,
167
+ ...(data !== undefined ? { data } : {}),
168
+ ...(autoResolved ? { autoResolved: true } : {}),
169
+ }
170
+ }
171
+
172
+ // ---------------------------------------------------------------------------
173
+ // DOM manipulation helpers
174
+ // ---------------------------------------------------------------------------
175
+
176
+ /**
177
+ * Finds the index of a direct child node within a parent element.
178
+ * Returns -1 if not found.
179
+ */
180
+ export function indexOfChildNode(parent: HTMLElement, child: Node): number {
181
+ const children = parent.childNodes
182
+ for (let i = 0; i < children.length; i++) {
183
+ if (children[i] === child) return i
184
+ }
185
+ return -1
186
+ }
187
+
188
+ /**
189
+ * Whether a direct editor child node produces a segment when read by
190
+ * `readSegmentsFromDOM` in use-prompt-area.ts. This is the single predicate
191
+ * shared with `domChildIndexToSegmentIndex` below, so a DOM child index can
192
+ * never map to a different segment index than the one the reader actually
193
+ * produces — decoration elements (the URL `<a>` from `decorateURLsInEditor`,
194
+ * the markdown `<span data-md>` from `decorateMarkdownInEditor`) fall through
195
+ * to the reader's "unknown element" branch and DO produce a text segment, so
196
+ * they must count here too, not just chips/text/`<br>`. A chip element only
197
+ * counts if `chipNodeToSegment` would actually accept it — the reader skips a
198
+ * chip missing a required attribute (trigger/value/display), so this must too.
199
+ */
200
+ export function childProducesSegment(child: Node): boolean {
201
+ if (child.nodeType === Node.TEXT_NODE) return (child.textContent ?? '') !== ''
202
+ if (isBRElement(child)) return !child.dataset.sentinel
203
+ if (isChipElement(child)) return chipNodeToSegment(child) !== null
204
+ if (isHTMLElement(child)) return (child.textContent ?? '') !== ''
205
+ return false
206
+ }
207
+
208
+ /**
209
+ * Maps the index of a direct child node within the editor to the index of the
210
+ * corresponding segment in the model array, by counting `childProducesSegment`
211
+ * matches up to (but not including) `childIndex`.
212
+ *
213
+ * Keeping this in one place ensures chip-removal, chip-revert, and chip
214
+ * in-place replacement all agree on the exact same mapping rules as the reader.
215
+ */
216
+ export function domChildIndexToSegmentIndex(editor: HTMLElement, childIndex: number): number {
217
+ let segIdx = 0
218
+ for (let i = 0; i < childIndex; i++) {
219
+ if (childProducesSegment(editor.childNodes[i])) segIdx++
220
+ }
221
+ return segIdx
222
+ }
223
+
224
+ /**
225
+ * Gets the direct child of `ancestor` that contains `descendant`.
226
+ * Walks up from descendant until we find a node whose parent is ancestor.
227
+ * Returns null if descendant is not inside ancestor.
228
+ */
229
+ export function getDirectChildContaining(ancestor: HTMLElement, descendant: Node): Node | null {
230
+ let node: Node | null = descendant
231
+ while (node !== null) {
232
+ if (node.parentNode === ancestor) return node
233
+ node = node.parentNode
234
+ }
235
+ return null
236
+ }
237
+
238
+ /**
239
+ * Unwraps a block element (div, p) by replacing it with its child nodes
240
+ * plus a trailing BR. Used for browser DOM normalization.
241
+ */
242
+ export function unwrapBlockElement(parent: HTMLElement, block: HTMLElement): void {
243
+ const fragment = document.createDocumentFragment()
244
+
245
+ // Move all children to fragment
246
+ while (block.firstChild) {
247
+ fragment.appendChild(block.firstChild)
248
+ }
249
+
250
+ // Add a BR after the unwrapped content
251
+ fragment.appendChild(document.createElement('br'))
252
+
253
+ parent.replaceChild(fragment, block)
254
+ }
255
+
256
+ /**
257
+ * Normalizes the editor DOM after browser mutations.
258
+ *
259
+ * Browsers insert various wrapper elements on Enter/paste:
260
+ * - Chrome wraps new lines in <div>
261
+ * - Safari may use <div><br></div>
262
+ * - Some use <p> tags
263
+ *
264
+ * This function unwraps all non-chip block elements, leaving only:
265
+ * - Text nodes
266
+ * - <br> elements
267
+ * - Chip <span> elements (with data-chip-trigger)
268
+ */
269
+ export function normalizeEditorDOM(editor: HTMLElement): boolean {
270
+ let changed = false
271
+ const blockTags = new Set(['DIV', 'P', 'SECTION', 'ARTICLE', 'BLOCKQUOTE'])
272
+
273
+ // Iterate backwards since we're modifying the DOM
274
+ for (let i = editor.childNodes.length - 1; i >= 0; i--) {
275
+ const child = editor.childNodes[i]
276
+
277
+ // Skip non-element nodes, chip elements, and BR elements
278
+ if (!(child instanceof HTMLElement)) continue
279
+ if (child.dataset.chipTrigger !== undefined) continue
280
+ if (child instanceof HTMLBRElement) continue
281
+
282
+ const tag = child.tagName
283
+ if (blockTags.has(tag)) {
284
+ unwrapBlockElement(editor, child)
285
+ changed = true
286
+ } else if (
287
+ tag === 'FONT' ||
288
+ tag === 'B' ||
289
+ tag === 'I' ||
290
+ tag === 'U' ||
291
+ tag === 'STRONG' ||
292
+ tag === 'EM' ||
293
+ tag === 'A' ||
294
+ tag === 'SPAN'
295
+ ) {
296
+ // Unwrap inline formatting/decoration elements (browser-inserted or markdown decorations)
297
+ const text = child.textContent ?? ''
298
+ if (text) {
299
+ editor.replaceChild(document.createTextNode(text), child)
300
+ } else {
301
+ editor.removeChild(child)
302
+ }
303
+ changed = true
304
+ }
305
+ }
306
+
307
+ // Merge adjacent text nodes
308
+ editor.normalize()
309
+
310
+ return changed
311
+ }
312
+
313
+ // ---------------------------------------------------------------------------
314
+ // URL decoration
315
+ // ---------------------------------------------------------------------------
316
+
317
+ /** URL pattern for detecting URLs in text content */
318
+ const URL_PATTERN = /https?:\/\/[^\s),]+/g
319
+
320
+ /**
321
+ * Walks direct-child text nodes in the editor and wraps URL text in
322
+ * `<a>` elements for visual styling and clickability.
323
+ *
324
+ * This is a DOM-only decoration — it does NOT modify the segment model.
325
+ * The `<a>` elements are stripped by `normalizeEditorDOM` on every input cycle,
326
+ * so they are re-applied fresh each time.
327
+ *
328
+ * @param editor - The contentEditable root element
329
+ * @returns Whether any decorations were applied
330
+ */
331
+ export function decorateURLsInEditor(editor: HTMLElement): boolean {
332
+ let decorated = false
333
+
334
+ // Collect text nodes first (avoid modifying while iterating)
335
+ const textNodes: Text[] = []
336
+ for (let i = 0; i < editor.childNodes.length; i++) {
337
+ const node = editor.childNodes[i]
338
+ if (isTextNode(node) && node.textContent) {
339
+ textNodes.push(node)
340
+ }
341
+ }
342
+
343
+ for (const textNode of textNodes) {
344
+ const text = textNode.textContent ?? ''
345
+ URL_PATTERN.lastIndex = 0
346
+ const matches: Array<{ url: string; index: number }> = []
347
+ let match: RegExpExecArray | null
348
+
349
+ while ((match = URL_PATTERN.exec(text)) !== null) {
350
+ // Trim trailing punctuation that's likely not part of the URL
351
+ let url = match[0]
352
+ while (url.length > 0 && /[.;:!?]$/.test(url)) {
353
+ url = url.slice(0, -1)
354
+ }
355
+ if (url.length > 0) {
356
+ matches.push({ url, index: match.index })
357
+ }
358
+ }
359
+
360
+ if (matches.length === 0) continue
361
+
362
+ const parent = textNode.parentNode
363
+ if (!parent) continue
364
+
365
+ // Validate URLs upfront – only keep those with safe protocols (CWE-79)
366
+ const safeMatches: Array<{ url: string; href: string; index: number }> = []
367
+ for (const { url, index } of matches) {
368
+ try {
369
+ const parsed = new URL(url)
370
+ if (parsed.protocol === 'http:' || parsed.protocol === 'https:') {
371
+ safeMatches.push({ url, href: parsed.href, index })
372
+ }
373
+ } catch {
374
+ // skip malformed URLs
375
+ }
376
+ }
377
+
378
+ if (safeMatches.length === 0) continue
379
+
380
+ decorated = true
381
+ const fragment = document.createDocumentFragment()
382
+ let lastIndex = 0
383
+
384
+ for (const { url, href, index } of safeMatches) {
385
+ // Text before this URL
386
+ if (index > lastIndex) {
387
+ fragment.appendChild(document.createTextNode(text.slice(lastIndex, index)))
388
+ }
389
+
390
+ // Create the link element
391
+ const anchor = document.createElement('a')
392
+ anchor.href = href
393
+ anchor.target = '_blank'
394
+ anchor.rel = 'noopener noreferrer'
395
+ anchor.dataset.url = 'true'
396
+ anchor.className = 'text-accent hover:text-accent-hover underline cursor-pointer'
397
+ anchor.textContent = url
398
+ fragment.appendChild(anchor)
399
+
400
+ lastIndex = index + url.length
401
+ }
402
+
403
+ // Text after the last URL
404
+ if (lastIndex < text.length) {
405
+ fragment.appendChild(document.createTextNode(text.slice(lastIndex)))
406
+ }
407
+
408
+ parent.replaceChild(fragment, textNode)
409
+ }
410
+
411
+ return decorated
412
+ }
413
+
414
+ // ---------------------------------------------------------------------------
415
+ // Markdown inline decoration
416
+ // ---------------------------------------------------------------------------
417
+
418
+ /** Pattern to find ***bold-italic***, **bold**, and *italic* markdown spans */
419
+ const MARKDOWN_INLINE_PATTERN = /(\*{3})(.+?)\*{3}|(\*{2})(.+?)\*{2}|(\*)(.+?)\*/g
420
+
421
+ /**
422
+ * Walks direct-child text nodes in the editor and wraps markdown-formatted
423
+ * text (`**bold**`, `*italic*`, `***bold-italic***`) in styled `<span>` elements.
424
+ *
425
+ * This is a DOM-only decoration — it does NOT modify the segment model.
426
+ * The `<span>` elements are stripped by `normalizeEditorDOM` on every input cycle,
427
+ * so they are re-applied fresh each time.
428
+ *
429
+ * The `*` markers stay visible in the text; only the CSS styling changes.
430
+ *
431
+ * @param editor - The contentEditable root element
432
+ * @returns Whether any decorations were applied
433
+ */
434
+ export function decorateMarkdownInEditor(editor: HTMLElement): boolean {
435
+ let decorated = false
436
+
437
+ // Collect text nodes first (avoid modifying while iterating)
438
+ const textNodes: Text[] = []
439
+ for (let i = 0; i < editor.childNodes.length; i++) {
440
+ const node = editor.childNodes[i]
441
+ if (isTextNode(node) && node.textContent) {
442
+ textNodes.push(node)
443
+ }
444
+ }
445
+
446
+ for (const textNode of textNodes) {
447
+ const text = textNode.textContent ?? ''
448
+ MARKDOWN_INLINE_PATTERN.lastIndex = 0
449
+ const matches: Array<{
450
+ fullMatch: string
451
+ marker: string
452
+ content: string
453
+ index: number
454
+ className: string
455
+ }> = []
456
+ let match: RegExpExecArray | null
457
+
458
+ while ((match = MARKDOWN_INLINE_PATTERN.exec(text)) !== null) {
459
+ if (match[1] && match[2]) {
460
+ // ***bold-italic***
461
+ matches.push({
462
+ fullMatch: match[0],
463
+ marker: match[1],
464
+ content: match[2],
465
+ index: match.index,
466
+ className: 'font-bold italic',
467
+ })
468
+ } else if (match[3] && match[4]) {
469
+ // **bold**
470
+ matches.push({
471
+ fullMatch: match[0],
472
+ marker: match[3],
473
+ content: match[4],
474
+ index: match.index,
475
+ className: 'font-bold',
476
+ })
477
+ } else if (match[5] && match[6]) {
478
+ // *italic*
479
+ matches.push({
480
+ fullMatch: match[0],
481
+ marker: match[5],
482
+ content: match[6],
483
+ index: match.index,
484
+ className: 'italic',
485
+ })
486
+ }
487
+ }
488
+
489
+ if (matches.length === 0) continue
490
+
491
+ decorated = true
492
+ const parent = textNode.parentNode
493
+ if (!parent) continue
494
+
495
+ const fragment = document.createDocumentFragment()
496
+ let lastIndex = 0
497
+
498
+ for (const { fullMatch, marker, content, index, className } of matches) {
499
+ // Text before this match
500
+ if (index > lastIndex) {
501
+ fragment.appendChild(document.createTextNode(text.slice(lastIndex, index)))
502
+ }
503
+
504
+ // Parent span — textContent still returns full match (e.g. "**world**")
505
+ const span = document.createElement('span')
506
+ span.dataset.md = 'true'
507
+
508
+ // Opening marker (visually hidden)
509
+ const openMarker = document.createElement('span')
510
+ openMarker.className = 'prompt-area-md-marker'
511
+ openMarker.textContent = marker
512
+
513
+ // Styled content
514
+ const styledContent = document.createElement('span')
515
+ styledContent.className = className
516
+ styledContent.textContent = content
517
+
518
+ // Closing marker (visually hidden)
519
+ const closeMarker = document.createElement('span')
520
+ closeMarker.className = 'prompt-area-md-marker'
521
+ closeMarker.textContent = marker
522
+
523
+ span.appendChild(openMarker)
524
+ span.appendChild(styledContent)
525
+ span.appendChild(closeMarker)
526
+ fragment.appendChild(span)
527
+
528
+ lastIndex = index + fullMatch.length
529
+ }
530
+
531
+ // Text after the last match
532
+ if (lastIndex < text.length) {
533
+ fragment.appendChild(document.createTextNode(text.slice(lastIndex)))
534
+ }
535
+
536
+ parent.replaceChild(fragment, textNode)
537
+ }
538
+
539
+ return decorated
540
+ }
541
+
542
+ /** Matches a `•` bullet glyph at the start of a line (optionally indented). */
543
+ const LIST_BULLET_PATTERN = /(^|\n)([ \t]*)•/g
544
+
545
+ /**
546
+ * Walks direct-child text nodes and wraps each line-leading `•` bullet glyph in
547
+ * a `<span class="prompt-area-list-bullet">` so CSS can size it up (the raw
548
+ * U+2022 glyph renders much smaller than the surrounding text).
549
+ *
550
+ * Like {@link decorateMarkdownInEditor}, this is a DOM-only decoration: the span
551
+ * is stripped by {@link normalizeEditorDOM} on every input cycle, so the `•`
552
+ * stays a plain character in the segment model and is re-decorated each render.
553
+ *
554
+ * @param editor - The contentEditable root element
555
+ * @returns Whether any decorations were applied
556
+ */
557
+ export function decorateBulletsInEditor(editor: HTMLElement): boolean {
558
+ let decorated = false
559
+
560
+ const textNodes: Text[] = []
561
+ for (let i = 0; i < editor.childNodes.length; i++) {
562
+ const node = editor.childNodes[i]
563
+ if (isTextNode(node) && node.textContent?.includes('•')) {
564
+ textNodes.push(node)
565
+ }
566
+ }
567
+
568
+ for (const textNode of textNodes) {
569
+ const text = textNode.textContent ?? ''
570
+ LIST_BULLET_PATTERN.lastIndex = 0
571
+ const bulletIndices: number[] = []
572
+ let match: RegExpExecArray | null
573
+ while ((match = LIST_BULLET_PATTERN.exec(text)) !== null) {
574
+ bulletIndices.push(match.index + match[1].length + match[2].length)
575
+ }
576
+
577
+ if (bulletIndices.length === 0) continue
578
+
579
+ decorated = true
580
+ const parent = textNode.parentNode
581
+ if (!parent) continue
582
+
583
+ const fragment = document.createDocumentFragment()
584
+ let lastIndex = 0
585
+
586
+ for (const index of bulletIndices) {
587
+ if (index > lastIndex) {
588
+ fragment.appendChild(document.createTextNode(text.slice(lastIndex, index)))
589
+ }
590
+ const span = document.createElement('span')
591
+ span.dataset.md = 'true'
592
+ span.className = 'prompt-area-list-bullet'
593
+ span.textContent = '•'
594
+ fragment.appendChild(span)
595
+ lastIndex = index + 1 // the bullet is a single character
596
+ }
597
+
598
+ if (lastIndex < text.length) {
599
+ fragment.appendChild(document.createTextNode(text.slice(lastIndex)))
600
+ }
601
+
602
+ parent.replaceChild(fragment, textNode)
603
+ }
604
+
605
+ return decorated
606
+ }
607
+
608
+ /**
609
+ * Matches the line-leading whitespace run of an indented list line (bullet or
610
+ * numbered). The lookahead keeps the list prefix itself out of the capture, so
611
+ * only the indentation is wrapped.
612
+ */
613
+ const LIST_INDENT_PATTERN = /(^|\n)([ \t]+)(?=(?:[•\-*] |\d+\. ))/g
614
+
615
+ /**
616
+ * Wraps each list line's leading indentation in an inline-block
617
+ * `<span class="prompt-area-list-indent">` sized per nesting level, so nested
618
+ * items read with a wide, Notion-like indent instead of the raw 2-space gap.
619
+ *
620
+ * Like the other decorations this is display-only: the span keeps the original
621
+ * whitespace as its textContent (so plain-text length and caret offsets are
622
+ * unchanged) and is stripped by {@link normalizeEditorDOM} each input cycle.
623
+ * Must run BEFORE the node-splitting passes ({@link decorateURLsInEditor},
624
+ * {@link decorateMarkdownInEditor}, {@link decorateBulletsInEditor}) so every
625
+ * direct-child text node is still a whole line — otherwise a mid-line split
626
+ * fragment beginning with whitespace would let the `^` anchor false-match
627
+ * non-line-leading whitespace.
628
+ *
629
+ * @returns Whether any decorations were applied
630
+ */
631
+ export function decorateListIndentInEditor(editor: HTMLElement): boolean {
632
+ let decorated = false
633
+
634
+ const textNodes: Text[] = []
635
+ for (let i = 0; i < editor.childNodes.length; i++) {
636
+ const node = editor.childNodes[i]
637
+ if (isTextNode(node)) textNodes.push(node)
638
+ }
639
+
640
+ for (const textNode of textNodes) {
641
+ const text = textNode.textContent ?? ''
642
+ LIST_INDENT_PATTERN.lastIndex = 0
643
+ const runs: { start: number; end: number }[] = []
644
+ let match: RegExpExecArray | null
645
+ while ((match = LIST_INDENT_PATTERN.exec(text)) !== null) {
646
+ const start = match.index + match[1].length
647
+ runs.push({ start, end: start + match[2].length })
648
+ }
649
+
650
+ if (runs.length === 0) continue
651
+
652
+ decorated = true
653
+ const parent = textNode.parentNode
654
+ if (!parent) continue
655
+
656
+ const fragment = document.createDocumentFragment()
657
+ let lastIndex = 0
658
+
659
+ for (const { start, end } of runs) {
660
+ if (start > lastIndex) {
661
+ fragment.appendChild(document.createTextNode(text.slice(lastIndex, start)))
662
+ }
663
+ const whitespace = text.slice(start, end)
664
+ const level = Math.floor(whitespace.length / 2)
665
+ const span = document.createElement('span')
666
+ span.dataset.md = 'true'
667
+ span.className = 'prompt-area-list-indent'
668
+ span.style.width = `calc(var(--prompt-area-indent-size, 1.5em) * ${level})`
669
+ span.textContent = whitespace
670
+ fragment.appendChild(span)
671
+ lastIndex = end
672
+ }
673
+
674
+ if (lastIndex < text.length) {
675
+ fragment.appendChild(document.createTextNode(text.slice(lastIndex)))
676
+ }
677
+
678
+ parent.replaceChild(fragment, textNode)
679
+ }
680
+
681
+ return decorated
682
+ }
683
+
684
+ /**
685
+ * Applies every display-only decoration to the editor in one pass: URL links
686
+ * always, plus markdown emphasis, list indentation, and list bullets when
687
+ * markdown mode is on. Each decoration is stripped by {@link normalizeEditorDOM}
688
+ * on the next input cycle and re-applied here, so the segment model is never
689
+ * mutated.
690
+ *
691
+ * List indentation runs FIRST, while each direct-child text node is still a
692
+ * whole line: the URL and markdown passes split text nodes mid-line, and a tail
693
+ * fragment starting with whitespace would let the indent regex's `^` anchor
694
+ * false-match non-line-leading whitespace (e.g. `see http://x 1. y`).
695
+ */
696
+ export function decorateEditor(editor: HTMLElement, markdownEnabled: boolean): void {
697
+ // Whole-line passes run FIRST, while each direct-child text node is still a
698
+ // full line. The URL and markdown passes split text nodes mid-line, so a tail
699
+ // fragment beginning with "•" would let the bullet regex's `^` anchor
700
+ // false-match a mid-line separator (e.g. `**bold** • middle`).
701
+ if (markdownEnabled) {
702
+ decorateListIndentInEditor(editor)
703
+ decorateBulletsInEditor(editor)
704
+ }
705
+ decorateURLsInEditor(editor)
706
+ if (markdownEnabled) decorateMarkdownInEditor(editor)
707
+ }
708
+
709
+ // ---------------------------------------------------------------------------
710
+ // Selection helpers
711
+ // ---------------------------------------------------------------------------
712
+
713
+ /**
714
+ * Returns the first Range from the current window selection, or null if
715
+ * there is no selection or it has no ranges.
716
+ */
717
+ export function getSelectionRange(): Range | null {
718
+ const sel = window.getSelection()
719
+ if (!sel || sel.rangeCount === 0) return null
720
+ return sel.getRangeAt(0)
721
+ }