@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,705 @@
1
+ /**
2
+ * Pure logic engine for the PromptArea component.
3
+ * No DOM dependencies - fully testable in Node.
4
+ */
5
+ import type { Segment, ChipSegment, TriggerConfig, TriggerPosition, ActiveTrigger } from './types.ts'
6
+
7
+ // ---------------------------------------------------------------------------
8
+ // Serialization
9
+ // ---------------------------------------------------------------------------
10
+
11
+ /**
12
+ * Converts an array of segments to a plain text string.
13
+ * Chips are represented as `{trigger}{displayText}` (e.g., "@Alice").
14
+ */
15
+ export function segmentsToPlainText(segments: Segment[]): string {
16
+ return segments
17
+ .map((seg) => {
18
+ if (seg.type === 'text') return seg.text
19
+ return `${seg.trigger}${seg.displayText}`
20
+ })
21
+ .join('')
22
+ }
23
+
24
+ /**
25
+ * Converts plain text into a single text segment.
26
+ * Used for initial value conversion from plain strings.
27
+ */
28
+ export function plainTextToSegments(text: string): Segment[] {
29
+ if (!text) return []
30
+ return [{ type: 'text', text }]
31
+ }
32
+
33
+ /**
34
+ * Truncates segments so their combined plain-text length is at most `maxLength`.
35
+ * Whole segments are kept while they fit; a text segment that crosses the limit
36
+ * is sliced to fit, and a chip that would cross the limit is dropped (a chip
37
+ * can't be partially represented).
38
+ */
39
+ export function truncateSegmentsToLength(segments: Segment[], maxLength: number): Segment[] {
40
+ if (maxLength <= 0) return []
41
+ const result: Segment[] = []
42
+ let length = 0
43
+ for (const seg of segments) {
44
+ const segLength =
45
+ seg.type === 'text' ? seg.text.length : seg.trigger.length + seg.displayText.length
46
+ if (length + segLength <= maxLength) {
47
+ result.push(seg)
48
+ length += segLength
49
+ continue
50
+ }
51
+ if (seg.type === 'text') {
52
+ let remaining = maxLength - length
53
+ if (remaining > 0) {
54
+ // Don't split a surrogate pair: if the cut lands right after a high
55
+ // surrogate, drop the incomplete code point instead of a lone surrogate.
56
+ const code = seg.text.charCodeAt(remaining - 1)
57
+ if (code >= 0xd800 && code <= 0xdbff) remaining -= 1
58
+ if (remaining > 0) result.push({ type: 'text', text: seg.text.slice(0, remaining) })
59
+ }
60
+ }
61
+ break
62
+ }
63
+ return result
64
+ }
65
+
66
+ // ---------------------------------------------------------------------------
67
+ // Whitespace / word boundaries
68
+ // ---------------------------------------------------------------------------
69
+
70
+ /**
71
+ * Single source of truth for what counts as an inline-whitespace word boundary
72
+ * in the editor model: a space, newline, or tab.
73
+ *
74
+ * Trigger detection, paste auto-resolution, and position validation all rely
75
+ * on the *same* notion of a boundary — keeping it here prevents the three
76
+ * call sites from silently drifting apart (e.g. one handling tabs and the
77
+ * others not).
78
+ */
79
+ export function isInlineWhitespace(char: string | undefined): boolean {
80
+ return char === ' ' || char === '\n' || char === '\t'
81
+ }
82
+
83
+ /**
84
+ * Builds a lookup from trigger character to its config. When two triggers
85
+ * share a character the first one wins, preserving the previous `Array.find`
86
+ * semantics while turning the per-character scan into an O(1) map read.
87
+ */
88
+ function buildTriggerCharMap(triggers: TriggerConfig[]): Map<string, TriggerConfig> {
89
+ const map = new Map<string, TriggerConfig>()
90
+ for (const trigger of triggers) {
91
+ if (!map.has(trigger.char)) map.set(trigger.char, trigger)
92
+ }
93
+ return map
94
+ }
95
+
96
+ // ---------------------------------------------------------------------------
97
+ // Trigger position validation
98
+ // ---------------------------------------------------------------------------
99
+
100
+ /**
101
+ * Checks whether a trigger character at the given position in text
102
+ * is valid according to the position rule.
103
+ *
104
+ * @param text - The full text content
105
+ * @param charIndex - The index of the trigger character in the text
106
+ * @param position - The position rule to validate against
107
+ */
108
+ export function isValidTriggerPosition(
109
+ text: string,
110
+ charIndex: number,
111
+ position: TriggerPosition,
112
+ ): boolean {
113
+ if (charIndex === 0) return true
114
+
115
+ const prevChar = text[charIndex - 1]
116
+
117
+ if (position === 'start') {
118
+ return prevChar === '\n'
119
+ }
120
+
121
+ // position === 'any': valid after any whitespace
122
+ return isInlineWhitespace(prevChar)
123
+ }
124
+
125
+ // ---------------------------------------------------------------------------
126
+ // Trigger detection
127
+ // ---------------------------------------------------------------------------
128
+
129
+ /**
130
+ * Scans backwards from the cursor position to detect if the user is
131
+ * currently typing a trigger word.
132
+ *
133
+ * Returns the active trigger info, or null if no trigger is active.
134
+ *
135
+ * @param text - The full plain text content
136
+ * @param cursorPos - The cursor position (character offset from start)
137
+ * @param triggers - Available trigger configurations
138
+ */
139
+ export function detectActiveTrigger(
140
+ text: string,
141
+ cursorPos: number,
142
+ triggers: TriggerConfig[],
143
+ ): ActiveTrigger | null {
144
+ if (!text || cursorPos === 0 || triggers.length === 0) return null
145
+
146
+ const triggerByChar = buildTriggerCharMap(triggers)
147
+
148
+ // Scan backwards from cursor to find the nearest trigger character.
149
+ // Stop at whitespace (trigger word has ended) or start of text.
150
+ for (let i = cursorPos - 1; i >= 0; i--) {
151
+ const char = text[i]
152
+
153
+ // If we hit whitespace before finding a trigger, check if this whitespace
154
+ // is immediately followed by a trigger character
155
+ if (isInlineWhitespace(char)) {
156
+ // The character after this whitespace could be a trigger
157
+ if (i + 1 < cursorPos) {
158
+ const nextChar = text[i + 1]
159
+ const matchingTrigger = triggerByChar.get(nextChar)
160
+ if (matchingTrigger && isValidTriggerPosition(text, i + 1, matchingTrigger.position)) {
161
+ return {
162
+ config: matchingTrigger,
163
+ startOffset: i + 1,
164
+ query: text.slice(i + 2, cursorPos),
165
+ }
166
+ }
167
+ }
168
+ // No trigger found after this whitespace, stop searching
169
+ return null
170
+ }
171
+
172
+ // Check if this character is a trigger character
173
+ const matchingTrigger = triggerByChar.get(char)
174
+ if (matchingTrigger && isValidTriggerPosition(text, i, matchingTrigger.position)) {
175
+ return {
176
+ config: matchingTrigger,
177
+ startOffset: i,
178
+ query: text.slice(i + 1, cursorPos),
179
+ }
180
+ }
181
+ }
182
+
183
+ return null
184
+ }
185
+
186
+ // ---------------------------------------------------------------------------
187
+ // Chip resolution
188
+ // ---------------------------------------------------------------------------
189
+
190
+ /**
191
+ * Resolves an active trigger into a chip within the segments array.
192
+ * Replaces the trigger text (trigger char + query) with a chip segment.
193
+ *
194
+ * @param segments - Current document segments
195
+ * @param activeTrigger - The active trigger to resolve
196
+ * @param chip - The chip data (value, displayText, optional data)
197
+ * @returns New segments array with the chip inserted, and the new cursor position
198
+ */
199
+ export function resolveChip(
200
+ segments: Segment[],
201
+ activeTrigger: ActiveTrigger,
202
+ chip: { value: string; displayText: string; data?: unknown; autoResolved?: boolean },
203
+ ): { segments: Segment[]; cursorOffset: number } {
204
+ const triggerStart = activeTrigger.startOffset
205
+ const triggerEnd = triggerStart + 1 + activeTrigger.query.length // +1 for trigger char
206
+
207
+ // Build the new segments by mapping plain text positions back to segment boundaries
208
+ const newSegments: Segment[] = []
209
+ let offset = 0
210
+
211
+ for (const seg of segments) {
212
+ if (seg.type === 'chip') {
213
+ const chipText = `${seg.trigger}${seg.displayText}`
214
+ const chipStart = offset
215
+ const chipEnd = offset + chipText.length
216
+
217
+ // If the trigger range overlaps with this chip, something is wrong.
218
+ // Chips should not be partially replaced.
219
+ if (chipEnd <= triggerStart || chipStart >= triggerEnd) {
220
+ newSegments.push(seg)
221
+ }
222
+ offset = chipEnd
223
+ } else {
224
+ const textStart = offset
225
+ const textEnd = offset + seg.text.length
226
+
227
+ if (textEnd <= triggerStart) {
228
+ // Entirely before the trigger - keep as-is
229
+ newSegments.push(seg)
230
+ } else if (textStart >= triggerEnd) {
231
+ // Entirely after the trigger - keep as-is
232
+ newSegments.push(seg)
233
+ } else {
234
+ // This text segment contains (part of) the trigger range
235
+ const beforeText = seg.text.slice(0, Math.max(0, triggerStart - textStart))
236
+ const afterText = seg.text.slice(Math.min(seg.text.length, triggerEnd - textStart))
237
+
238
+ if (beforeText) {
239
+ newSegments.push({ type: 'text', text: beforeText })
240
+ }
241
+
242
+ const newChip: ChipSegment = {
243
+ type: 'chip',
244
+ trigger: activeTrigger.config.char,
245
+ value: chip.value,
246
+ displayText: chip.displayText,
247
+ ...(chip.data !== undefined ? { data: chip.data } : {}),
248
+ ...(chip.autoResolved ? { autoResolved: true } : {}),
249
+ }
250
+ newSegments.push(newChip)
251
+
252
+ // Add trailing space after chip, then any remaining text
253
+ if (afterText) {
254
+ newSegments.push({ type: 'text', text: ' ' + afterText.replace(/^\s/, '') })
255
+ } else {
256
+ newSegments.push({ type: 'text', text: ' ' })
257
+ }
258
+ }
259
+
260
+ offset = textEnd
261
+ }
262
+ }
263
+
264
+ // Merge adjacent text segments
265
+ const merged = mergeAdjacentTextSegments(newSegments)
266
+
267
+ // Cursor should be placed after the chip + trailing space.
268
+ // Find the *last* matching chip so duplicates resolve correctly.
269
+ let lastChipEndOffset = -1
270
+ let runningOffset = 0
271
+ for (const seg of merged) {
272
+ if (seg.type === 'text') {
273
+ runningOffset += seg.text.length
274
+ } else {
275
+ runningOffset += seg.trigger.length + seg.displayText.length
276
+ if (
277
+ seg.value === chip.value &&
278
+ seg.displayText === chip.displayText &&
279
+ seg.trigger === activeTrigger.config.char
280
+ ) {
281
+ lastChipEndOffset = runningOffset
282
+ }
283
+ }
284
+ }
285
+ // +1 accounts for the trailing space after the chip
286
+ const cursorOffset = lastChipEndOffset === -1 ? runningOffset : lastChipEndOffset + 1
287
+
288
+ return { segments: merged, cursorOffset }
289
+ }
290
+
291
+ // ---------------------------------------------------------------------------
292
+ // Chip removal
293
+ // ---------------------------------------------------------------------------
294
+
295
+ /**
296
+ * Removes a chip at the given segment index and merges adjacent text segments.
297
+ *
298
+ * @param segments - Current document segments
299
+ * @param index - The segment index to remove
300
+ * @returns New segments array with the chip removed
301
+ */
302
+ export function removeChipAtIndex(segments: Segment[], index: number): Segment[] {
303
+ if (index < 0 || index >= segments.length) return segments
304
+ if (segments[index].type !== 'chip') return segments
305
+
306
+ const result = [...segments.slice(0, index), ...segments.slice(index + 1)]
307
+ return mergeAdjacentTextSegments(result)
308
+ }
309
+
310
+ /**
311
+ * Reverts an auto-resolved chip at the given segment index back to plain text.
312
+ * The text includes the trigger character + display text (e.g., "#readme").
313
+ *
314
+ * @param segments - Current document segments
315
+ * @param index - The segment index to revert
316
+ * @returns New segments with the chip replaced by text, or null if not applicable
317
+ */
318
+ export function revertChipAtIndex(
319
+ segments: Segment[],
320
+ index: number,
321
+ ): { segments: Segment[]; revertedText: string } | null {
322
+ if (index < 0 || index >= segments.length) return null
323
+ const seg = segments[index]
324
+ if (seg.type !== 'chip' || !seg.autoResolved) return null
325
+
326
+ const revertedText = `${seg.trigger}${seg.displayText}`
327
+ const result = [
328
+ ...segments.slice(0, index),
329
+ { type: 'text' as const, text: revertedText },
330
+ ...segments.slice(index + 1),
331
+ ]
332
+ return { segments: mergeAdjacentTextSegments(result), revertedText }
333
+ }
334
+
335
+ // ---------------------------------------------------------------------------
336
+ // Paste: resolve trigger patterns in segments
337
+ // ---------------------------------------------------------------------------
338
+
339
+ /**
340
+ * Scans text segments for trigger patterns and auto-resolves them into chips.
341
+ * Only resolves triggers that have `resolveOnSpace: true`.
342
+ *
343
+ * Trigger patterns must appear at word boundaries: start of text, after
344
+ * whitespace, or after a newline. This avoids false positives like email
345
+ * addresses (user@example.com).
346
+ */
347
+ export function resolveTriggersInSegments(
348
+ segments: Segment[],
349
+ triggers: TriggerConfig[],
350
+ ): Segment[] {
351
+ const autoResolveTriggers = triggers.filter((t) => t.resolveOnSpace)
352
+ if (autoResolveTriggers.length === 0) return segments
353
+
354
+ const triggerByChar = buildTriggerCharMap(autoResolveTriggers)
355
+ const result: Segment[] = []
356
+
357
+ for (const seg of segments) {
358
+ if (seg.type === 'chip') {
359
+ result.push(seg)
360
+ continue
361
+ }
362
+
363
+ const parts = splitTextByTriggerPatterns(seg.text, triggerByChar)
364
+ result.push(...parts)
365
+ }
366
+
367
+ return mergeAdjacentTextSegments(result)
368
+ }
369
+
370
+ /**
371
+ * Splits a text string into text and chip segments based on trigger patterns.
372
+ * A trigger pattern is: (start-of-string | whitespace) + trigger_char + word_chars
373
+ * followed by whitespace or end-of-string.
374
+ */
375
+ function splitTextByTriggerPatterns(
376
+ text: string,
377
+ triggerByChar: Map<string, TriggerConfig>,
378
+ ): Segment[] {
379
+ if (!text) return []
380
+
381
+ const segments: Segment[] = []
382
+ let i = 0
383
+
384
+ while (i < text.length) {
385
+ const char = text[i]
386
+
387
+ if (triggerByChar.has(char)) {
388
+ const isAtBoundary = i === 0 || isInlineWhitespace(text[i - 1])
389
+
390
+ if (isAtBoundary) {
391
+ const trigger = triggerByChar.get(char)
392
+ if (trigger && isValidTriggerPosition(text, i, trigger.position)) {
393
+ let end = i + 1
394
+ while (end < text.length && !isInlineWhitespace(text[end])) {
395
+ end++
396
+ }
397
+
398
+ const query = text.slice(i + 1, end)
399
+ if (query.length > 0) {
400
+ // Treat both undefined and '' from onSelect as "no custom label"
401
+ // and fall back to the query — an empty displayText would render
402
+ // a blank chip.
403
+ const displayText = trigger.onSelect?.({ value: query, label: query }) || query
404
+ segments.push({
405
+ type: 'chip',
406
+ trigger: char,
407
+ value: query,
408
+ displayText,
409
+ autoResolved: true,
410
+ })
411
+ i = end
412
+ continue
413
+ }
414
+ }
415
+ }
416
+ }
417
+
418
+ const start = i
419
+ i++
420
+ while (i < text.length && !(triggerByChar.has(text[i]) && isInlineWhitespace(text[i - 1]))) {
421
+ i++
422
+ }
423
+ segments.push({ type: 'text', text: text.slice(start, i) })
424
+ }
425
+
426
+ return segments
427
+ }
428
+
429
+ // ---------------------------------------------------------------------------
430
+ // Text range replacement
431
+ // ---------------------------------------------------------------------------
432
+
433
+ /**
434
+ * Replaces a range of plain text within the segments array.
435
+ * Handles segment boundaries correctly, preserving chip segments.
436
+ *
437
+ * @param segments - Current document segments
438
+ * @param start - Start offset in plain text
439
+ * @param end - End offset in plain text
440
+ * @param replacement - The replacement text
441
+ * @returns New segments array with the replacement applied
442
+ */
443
+ export function replaceTextRange(
444
+ segments: Segment[],
445
+ start: number,
446
+ end: number,
447
+ replacement: string,
448
+ ): Segment[] {
449
+ const newSegments: Segment[] = []
450
+ let offset = 0
451
+ let inserted = false
452
+
453
+ for (const seg of segments) {
454
+ if (seg.type === 'chip') {
455
+ const chipText = `${seg.trigger}${seg.displayText}`
456
+ const chipStart = offset
457
+ const chipEnd = offset + chipText.length
458
+
459
+ // For insertion (start === end), insert before this chip if position matches
460
+ if (!inserted && start === end && chipStart === start) {
461
+ newSegments.push({ type: 'text', text: replacement })
462
+ inserted = true
463
+ }
464
+
465
+ if (chipEnd <= start || chipStart >= end) {
466
+ newSegments.push(seg)
467
+ }
468
+ // Chips within the range are removed
469
+ offset = chipEnd
470
+ } else {
471
+ const textStart = offset
472
+ const textEnd = offset + seg.text.length
473
+
474
+ // Check if this segment contains the insertion/replacement point
475
+ const isBefore = start === end ? textEnd < start : textEnd <= start
476
+ const isAfter = start === end ? textStart > end : textStart >= end
477
+
478
+ if (isBefore) {
479
+ // Entirely before the range
480
+ newSegments.push(seg)
481
+ } else if (isAfter) {
482
+ // Entirely after the range
483
+ newSegments.push(seg)
484
+ } else {
485
+ // Overlaps with the range (or contains the insertion point)
486
+ const beforeText = seg.text.slice(0, Math.max(0, start - textStart))
487
+ const afterText = seg.text.slice(Math.min(seg.text.length, end - textStart))
488
+
489
+ if (beforeText) {
490
+ newSegments.push({ type: 'text', text: beforeText })
491
+ }
492
+ // Insert replacement only once (when we first enter the range)
493
+ if (!inserted && textStart <= start) {
494
+ newSegments.push({ type: 'text', text: replacement })
495
+ inserted = true
496
+ }
497
+ if (afterText) {
498
+ newSegments.push({ type: 'text', text: afterText })
499
+ }
500
+ }
501
+
502
+ offset = textEnd
503
+ }
504
+ }
505
+
506
+ // Fallback: if replacement wasn't inserted (e.g., insertion at very end)
507
+ if (!inserted && replacement) {
508
+ newSegments.push({ type: 'text', text: replacement })
509
+ }
510
+
511
+ return mergeAdjacentTextSegments(newSegments)
512
+ }
513
+
514
+ // ---------------------------------------------------------------------------
515
+ // Markdown formatting shortcuts
516
+ // ---------------------------------------------------------------------------
517
+
518
+ /**
519
+ * Toggles markdown wrap markers around a selected text range.
520
+ * If the selection is already wrapped with the marker, unwraps it.
521
+ * If not wrapped, wraps it.
522
+ *
523
+ * @param segments - Current document segments
524
+ * @param selectionStart - Start offset in plain text
525
+ * @param selectionEnd - End offset in plain text
526
+ * @param marker - The markdown marker (e.g., '**' for bold, '*' for italic)
527
+ * @returns New segments and selection offsets, or null if selection is collapsed
528
+ */
529
+ export function toggleMarkdownWrap(
530
+ segments: Segment[],
531
+ selectionStart: number,
532
+ selectionEnd: number,
533
+ marker: string,
534
+ ): { segments: Segment[]; selectionStart: number; selectionEnd: number } | null {
535
+ if (selectionStart === selectionEnd) return null
536
+
537
+ const plainText = segmentsToPlainText(segments)
538
+ const markerLen = marker.length
539
+
540
+ // Check if already wrapped
541
+ const hasOpeningMarker =
542
+ selectionStart >= markerLen &&
543
+ plainText.slice(selectionStart - markerLen, selectionStart) === marker
544
+ const hasClosingMarker =
545
+ selectionEnd + markerLen <= plainText.length &&
546
+ plainText.slice(selectionEnd, selectionEnd + markerLen) === marker
547
+
548
+ let isWrapped = hasOpeningMarker && hasClosingMarker
549
+
550
+ // For single-char markers (e.g., '*'), ensure we're not matching
551
+ // inside a multi-char marker (e.g., '**')
552
+ if (isWrapped && markerLen === 1) {
553
+ const charBeforeOpening =
554
+ selectionStart > markerLen ? plainText[selectionStart - markerLen - 1] : ''
555
+ const charAfterClosing =
556
+ selectionEnd + markerLen < plainText.length ? plainText[selectionEnd + markerLen] : ''
557
+ if (charBeforeOpening === marker || charAfterClosing === marker) {
558
+ isWrapped = false
559
+ }
560
+ }
561
+
562
+ if (isWrapped) {
563
+ // Unwrap: remove closing marker first (preserves start offsets), then opening
564
+ const afterClosing = replaceTextRange(segments, selectionEnd, selectionEnd + markerLen, '')
565
+ const afterOpening = replaceTextRange(
566
+ afterClosing,
567
+ selectionStart - markerLen,
568
+ selectionStart,
569
+ '',
570
+ )
571
+ return {
572
+ segments: afterOpening,
573
+ selectionStart: selectionStart - markerLen,
574
+ selectionEnd: selectionEnd - markerLen,
575
+ }
576
+ }
577
+
578
+ // Wrap: insert closing marker first (preserves start offsets), then opening
579
+ const afterClosing = replaceTextRange(segments, selectionEnd, selectionEnd, marker)
580
+ const afterOpening = replaceTextRange(afterClosing, selectionStart, selectionStart, marker)
581
+ return {
582
+ segments: afterOpening,
583
+ selectionStart: selectionStart + markerLen,
584
+ selectionEnd: selectionEnd + markerLen,
585
+ }
586
+ }
587
+
588
+ // ---------------------------------------------------------------------------
589
+ // Inline markdown parsing
590
+ // ---------------------------------------------------------------------------
591
+
592
+ export type MarkdownToken =
593
+ | { type: 'plain'; text: string }
594
+ | { type: 'bold'; text: string }
595
+ | { type: 'italic'; text: string }
596
+ | { type: 'bold-italic'; text: string }
597
+ | { type: 'url'; text: string }
598
+
599
+ /**
600
+ * Parses text for simple inline markdown: bold, italic, bold-italic, and URLs.
601
+ * Does NOT handle block-level markdown (lists, headings, etc.).
602
+ */
603
+ export function parseInlineMarkdown(text: string): MarkdownToken[] {
604
+ if (!text) return []
605
+
606
+ const tokens: MarkdownToken[] = []
607
+ // Regex patterns for inline markdown elements:
608
+ // 1. ***text*** or ___text___ -> bold-italic
609
+ // 2. **text** or __text__ -> bold
610
+ // 3. *text* or _text_ -> italic
611
+ // 4. https://... or http://... -> URL
612
+ const pattern = /(\*{3}(.+?)\*{3})|(\*{2}(.+?)\*{2})|(\*(.+?)\*)|(https?:\/\/[^\s),]+)/g
613
+
614
+ let lastIndex = 0
615
+ let match: RegExpExecArray | null
616
+
617
+ while ((match = pattern.exec(text)) !== null) {
618
+ // Add any plain text before this match
619
+ if (match.index > lastIndex) {
620
+ tokens.push({ type: 'plain', text: text.slice(lastIndex, match.index) })
621
+ }
622
+
623
+ if (match[1] && match[2]) {
624
+ // ***bold-italic***
625
+ tokens.push({ type: 'bold-italic', text: match[2] })
626
+ } else if (match[3] && match[4]) {
627
+ // **bold**
628
+ tokens.push({ type: 'bold', text: match[4] })
629
+ } else if (match[5] && match[6]) {
630
+ // *italic*
631
+ tokens.push({ type: 'italic', text: match[6] })
632
+ } else if (match[7]) {
633
+ // URL
634
+ tokens.push({ type: 'url', text: match[7] })
635
+ }
636
+
637
+ lastIndex = match.index + match[0].length
638
+ }
639
+
640
+ // Add any remaining plain text
641
+ if (lastIndex < text.length) {
642
+ tokens.push({ type: 'plain', text: text.slice(lastIndex) })
643
+ }
644
+
645
+ return tokens
646
+ }
647
+
648
+ // ---------------------------------------------------------------------------
649
+ // Segment comparison
650
+ // ---------------------------------------------------------------------------
651
+
652
+ /**
653
+ * Shallow equality check for two segment arrays.
654
+ * Compares type, text, trigger, value, displayText, and autoResolved fields.
655
+ * Avoids JSON.stringify overhead for the common case.
656
+ */
657
+ export function segmentsEqual(a: Segment[], b: Segment[]): boolean {
658
+ if (a === b) return true
659
+ if (a.length !== b.length) return false
660
+
661
+ for (let i = 0; i < a.length; i++) {
662
+ const sa = a[i]
663
+ const sb = b[i]
664
+ if (sa.type !== sb.type) return false
665
+ if (sa.type === 'text') {
666
+ if (sb.type !== 'text' || sa.text !== sb.text) return false
667
+ } else {
668
+ if (
669
+ sb.type !== 'chip' ||
670
+ sa.trigger !== sb.trigger ||
671
+ sa.value !== sb.value ||
672
+ sa.displayText !== sb.displayText ||
673
+ sa.autoResolved !== sb.autoResolved
674
+ )
675
+ return false
676
+ }
677
+ }
678
+ return true
679
+ }
680
+
681
+ // ---------------------------------------------------------------------------
682
+ // Helpers
683
+ // ---------------------------------------------------------------------------
684
+
685
+ /**
686
+ * Merges adjacent text segments into single text segments.
687
+ * Also removes empty text segments.
688
+ */
689
+ export function mergeAdjacentTextSegments(segments: Segment[]): Segment[] {
690
+ const result: Segment[] = []
691
+
692
+ for (const seg of segments) {
693
+ if (seg.type === 'text' && seg.text === '') continue
694
+
695
+ const last = result[result.length - 1]
696
+ if (seg.type === 'text' && last?.type === 'text') {
697
+ // Merge with previous text segment
698
+ result[result.length - 1] = { type: 'text', text: last.text + seg.text }
699
+ } else {
700
+ result.push(seg)
701
+ }
702
+ }
703
+
704
+ return result
705
+ }