@workerdeck/ui 0.23.0 → 1.1.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 (145) hide show
  1. package/build/{SessionPanel-DUt2VzXG.mjs → SessionPanel-I5QSLLb0.mjs} +2266 -3459
  2. package/build/SessionPanel-I5QSLLb0.mjs.map +1 -0
  3. package/build/SessionPanel-yNO60Tzw.d.mts +151 -0
  4. package/build/format-C_lpFq0l.d.mts +16 -0
  5. package/build/format.d.mts +8 -76
  6. package/build/format.mjs +2 -2
  7. package/build/index.d.mts +291 -1695
  8. package/build/index.mjs +6 -301
  9. package/build/index.mjs.map +1 -1
  10. package/build/scoped.css +32 -23
  11. package/build/{status-BE-zg88x.mjs → status-Dm9INVTt.mjs} +17 -88
  12. package/build/status-Dm9INVTt.mjs.map +1 -0
  13. package/build/workspace.d.mts +8 -187
  14. package/build/workspace.mjs +13 -144
  15. package/build/workspace.mjs.map +1 -1
  16. package/package.json +24 -24
  17. package/src/components/agent/CodeEditor.tsx +31 -103
  18. package/src/components/agent/Composer.tsx +114 -333
  19. package/src/components/agent/ContextDialog.tsx +20 -41
  20. package/src/components/agent/ContextRing.tsx +6 -28
  21. package/src/components/agent/Conversation.tsx +21 -33
  22. package/src/components/agent/EditorTabs.tsx +39 -67
  23. package/src/components/agent/EngineIcon.tsx +29 -72
  24. package/src/components/agent/EntryIcon.tsx +16 -0
  25. package/src/components/agent/FileCard.tsx +10 -16
  26. package/src/components/agent/FileTree.tsx +52 -125
  27. package/src/components/agent/FileViewer.tsx +30 -60
  28. package/src/components/agent/HostFilesDialog.tsx +40 -69
  29. package/src/components/agent/Loader.tsx +3 -24
  30. package/src/components/agent/McpDialog.tsx +64 -137
  31. package/src/components/agent/Message.tsx +2 -21
  32. package/src/components/agent/ModelSelect.tsx +33 -62
  33. package/src/components/agent/PermissionModeSelect.tsx +21 -79
  34. package/src/components/agent/PermissionPrompt.tsx +59 -63
  35. package/src/components/agent/ProjectIcon.tsx +228 -93
  36. package/src/components/agent/PromptTokenText.tsx +10 -16
  37. package/src/components/agent/QuestionPrompt.tsx +43 -64
  38. package/src/components/agent/Reasoning.tsx +13 -16
  39. package/src/components/agent/Response.tsx +3 -9
  40. package/src/components/agent/Scrubber.tsx +31 -80
  41. package/src/components/agent/SessionBrowser.tsx +64 -243
  42. package/src/components/agent/SessionEmptyState.tsx +10 -40
  43. package/src/components/agent/SessionInfoDialog.tsx +46 -78
  44. package/src/components/agent/SessionItem.tsx +53 -320
  45. package/src/components/agent/SessionList.tsx +23 -39
  46. package/src/components/agent/SessionPanel.tsx +315 -996
  47. package/src/components/agent/SessionStatusIcon.tsx +14 -28
  48. package/src/components/agent/SessionSteps.tsx +34 -187
  49. package/src/components/agent/SessionWorkspace.tsx +104 -219
  50. package/src/components/agent/SkillsDialog.tsx +35 -80
  51. package/src/components/agent/StatusBar.tsx +51 -136
  52. package/src/components/agent/SubagentStrip.tsx +12 -77
  53. package/src/components/agent/ToolCallCard.tsx +94 -135
  54. package/src/components/agent/Transcript.tsx +103 -1288
  55. package/src/components/agent/TranscriptItemView.tsx +155 -0
  56. package/src/components/agent/TranscriptRows.tsx +418 -0
  57. package/src/components/agent/UsageDialog.tsx +7 -29
  58. package/src/components/agent/UsageMeters.tsx +18 -73
  59. package/src/components/agent/pulse.tsx +9 -32
  60. package/src/components/agent/scrubber-marks.ts +33 -103
  61. package/src/components/agent/status.ts +1 -5
  62. package/src/components/agent/tool-result-fetch.tsx +3 -25
  63. package/src/components/agent/tool-result-image.tsx +32 -111
  64. package/src/components/agent/transcript-rows.ts +41 -118
  65. package/src/components/agent/transcript-variant.tsx +3 -80
  66. package/src/components/agent/use-height-epoch.ts +10 -24
  67. package/src/components/agent/use-path-links.ts +45 -63
  68. package/src/components/agent/use-subagent-frame.ts +106 -0
  69. package/src/components/agent/use-transcript-jumps.ts +49 -107
  70. package/src/components/prompt-area/animated-placeholder.tsx +6 -5
  71. package/src/components/prompt-area/clipboard-helpers.ts +26 -21
  72. package/src/components/prompt-area/cursor-helpers.ts +79 -37
  73. package/src/components/prompt-area/dom-helpers.ts +97 -69
  74. package/src/components/prompt-area/file-strip.tsx +90 -80
  75. package/src/components/prompt-area/html-to-markdown.ts +67 -46
  76. package/src/components/prompt-area/image-strip.tsx +8 -18
  77. package/src/components/prompt-area/index.ts +2 -15
  78. package/src/components/prompt-area/prompt-area-engine.ts +100 -115
  79. package/src/components/prompt-area/prompt-area-list-ops.ts +66 -59
  80. package/src/components/prompt-area/prompt-area.tsx +31 -38
  81. package/src/components/prompt-area/remove-button.tsx +3 -9
  82. package/src/components/prompt-area/segment-helpers.ts +4 -12
  83. package/src/components/prompt-area/trigger-popover.tsx +15 -27
  84. package/src/components/prompt-area/trigger-presets.ts +0 -10
  85. package/src/components/prompt-area/types.ts +3 -8
  86. package/src/components/prompt-area/use-chip-editing.ts +305 -0
  87. package/src/components/prompt-area/use-markdown-mode.ts +7 -17
  88. package/src/components/prompt-area/use-prompt-area-events.ts +80 -86
  89. package/src/components/prompt-area/use-prompt-area-keydown.ts +598 -0
  90. package/src/components/prompt-area/use-prompt-area-state.ts +5 -23
  91. package/src/components/prompt-area/use-prompt-area.ts +170 -896
  92. package/src/components/prompt-area/use-trigger-search.ts +21 -19
  93. package/src/components/terminal/PermissionPrompt.tsx +56 -59
  94. package/src/components/terminal/QuestionPrompt.tsx +28 -79
  95. package/src/components/terminal/StatusLine.tsx +13 -63
  96. package/src/components/terminal/TerminalTranscript.tsx +44 -160
  97. package/src/components/terminal/affordances.tsx +68 -98
  98. package/src/components/terminal/blocks.ts +20 -156
  99. package/src/components/terminal/diff.tsx +19 -67
  100. package/src/components/terminal/height.ts +163 -313
  101. package/src/components/terminal/image-box.ts +0 -44
  102. package/src/components/terminal/items.tsx +150 -305
  103. package/src/components/terminal/markdown.tsx +36 -95
  104. package/src/components/terminal/press.tsx +26 -53
  105. package/src/components/terminal/prompt.tsx +40 -170
  106. package/src/components/terminal/result-preview.ts +8 -56
  107. package/src/components/terminal/row.tsx +11 -98
  108. package/src/components/terminal/scrubber.tsx +108 -401
  109. package/src/components/terminal/surface.tsx +6 -54
  110. package/src/components/terminal/todos.ts +66 -0
  111. package/src/components/terminal/tool-run.ts +21 -192
  112. package/src/components/ui/AlertDialog.tsx +8 -23
  113. package/src/components/ui/Badge.tsx +18 -29
  114. package/src/components/ui/Button.tsx +7 -15
  115. package/src/components/ui/Card.tsx +2 -13
  116. package/src/components/ui/CodeBlock.tsx +10 -37
  117. package/src/components/ui/CopyButton.tsx +10 -23
  118. package/src/components/ui/Dialog.tsx +23 -36
  119. package/src/components/ui/Empty.tsx +6 -29
  120. package/src/components/ui/Input.tsx +14 -16
  121. package/src/components/ui/Menu.tsx +6 -18
  122. package/src/components/ui/PortalScope.tsx +1 -21
  123. package/src/components/ui/ProgressRing.tsx +8 -19
  124. package/src/components/ui/Select.tsx +18 -31
  125. package/src/components/ui/Sonner.tsx +1 -2
  126. package/src/components/ui/Spinner.tsx +1 -1
  127. package/src/components/ui/Splitter.tsx +30 -64
  128. package/src/components/ui/Textarea.tsx +2 -5
  129. package/src/components/ui/Tooltip.tsx +3 -13
  130. package/src/format.ts +0 -9
  131. package/src/index.ts +16 -113
  132. package/src/lib/clipboard.ts +8 -28
  133. package/src/lib/css.ts +8 -0
  134. package/src/lib/format.ts +101 -86
  135. package/src/lib/plan-request.ts +16 -0
  136. package/src/lib/status.ts +45 -95
  137. package/src/lib/tool-icon.ts +39 -47
  138. package/src/lib/utils.ts +1 -3
  139. package/src/styles/terminal.css +81 -49
  140. package/src/styles/theme.css +96 -32
  141. package/src/workspace.ts +1 -24
  142. package/build/SessionPanel-BobynUo4.d.mts +0 -669
  143. package/build/SessionPanel-DUt2VzXG.mjs.map +0 -1
  144. package/build/format-ljc3lKpA.d.mts +0 -59
  145. package/build/status-BE-zg88x.mjs.map +0 -1
@@ -4,9 +4,7 @@
4
4
  */
5
5
  import type { Segment, ChipSegment, TriggerConfig, TriggerPosition, ActiveTrigger } from './types.ts'
6
6
 
7
- // ---------------------------------------------------------------------------
8
7
  // Serialization
9
- // ---------------------------------------------------------------------------
10
8
 
11
9
  /**
12
10
  * Converts an array of segments to a plain text string.
@@ -15,7 +13,9 @@ import type { Segment, ChipSegment, TriggerConfig, TriggerPosition, ActiveTrigge
15
13
  export function segmentsToPlainText(segments: Segment[]): string {
16
14
  return segments
17
15
  .map((seg) => {
18
- if (seg.type === 'text') return seg.text
16
+ if (seg.type === 'text') {
17
+ return seg.text
18
+ }
19
19
  return `${seg.trigger}${seg.displayText}`
20
20
  })
21
21
  .join('')
@@ -26,7 +26,9 @@ export function segmentsToPlainText(segments: Segment[]): string {
26
26
  * Used for initial value conversion from plain strings.
27
27
  */
28
28
  export function plainTextToSegments(text: string): Segment[] {
29
- if (!text) return []
29
+ if (!text) {
30
+ return []
31
+ }
30
32
  return [{ type: 'text', text }]
31
33
  }
32
34
 
@@ -37,12 +39,13 @@ export function plainTextToSegments(text: string): Segment[] {
37
39
  * can't be partially represented).
38
40
  */
39
41
  export function truncateSegmentsToLength(segments: Segment[], maxLength: number): Segment[] {
40
- if (maxLength <= 0) return []
42
+ if (maxLength <= 0) {
43
+ return []
44
+ }
41
45
  const result: Segment[] = []
42
46
  let length = 0
43
47
  for (const seg of segments) {
44
- const segLength =
45
- seg.type === 'text' ? seg.text.length : seg.trigger.length + seg.displayText.length
48
+ const segLength = seg.type === 'text' ? seg.text.length : seg.trigger.length + seg.displayText.length
46
49
  if (length + segLength <= maxLength) {
47
50
  result.push(seg)
48
51
  length += segLength
@@ -54,8 +57,12 @@ export function truncateSegmentsToLength(segments: Segment[], maxLength: number)
54
57
  // Don't split a surrogate pair: if the cut lands right after a high
55
58
  // surrogate, drop the incomplete code point instead of a lone surrogate.
56
59
  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) })
60
+ if (code >= 0xd800 && code <= 0xdbff) {
61
+ remaining -= 1
62
+ }
63
+ if (remaining > 0) {
64
+ result.push({ type: 'text', text: seg.text.slice(0, remaining) })
65
+ }
59
66
  }
60
67
  }
61
68
  break
@@ -63,9 +70,7 @@ export function truncateSegmentsToLength(segments: Segment[], maxLength: number)
63
70
  return result
64
71
  }
65
72
 
66
- // ---------------------------------------------------------------------------
67
73
  // Whitespace / word boundaries
68
- // ---------------------------------------------------------------------------
69
74
 
70
75
  /**
71
76
  * Single source of truth for what counts as an inline-whitespace word boundary
@@ -88,14 +93,14 @@ export function isInlineWhitespace(char: string | undefined): boolean {
88
93
  function buildTriggerCharMap(triggers: TriggerConfig[]): Map<string, TriggerConfig> {
89
94
  const map = new Map<string, TriggerConfig>()
90
95
  for (const trigger of triggers) {
91
- if (!map.has(trigger.char)) map.set(trigger.char, trigger)
96
+ if (!map.has(trigger.char)) {
97
+ map.set(trigger.char, trigger)
98
+ }
92
99
  }
93
100
  return map
94
101
  }
95
102
 
96
- // ---------------------------------------------------------------------------
97
103
  // Trigger position validation
98
- // ---------------------------------------------------------------------------
99
104
 
100
105
  /**
101
106
  * Checks whether a trigger character at the given position in text
@@ -105,12 +110,10 @@ function buildTriggerCharMap(triggers: TriggerConfig[]): Map<string, TriggerConf
105
110
  * @param charIndex - The index of the trigger character in the text
106
111
  * @param position - The position rule to validate against
107
112
  */
108
- export function isValidTriggerPosition(
109
- text: string,
110
- charIndex: number,
111
- position: TriggerPosition,
112
- ): boolean {
113
- if (charIndex === 0) return true
113
+ export function isValidTriggerPosition(text: string, charIndex: number, position: TriggerPosition): boolean {
114
+ if (charIndex === 0) {
115
+ return true
116
+ }
114
117
 
115
118
  const prevChar = text[charIndex - 1]
116
119
 
@@ -122,9 +125,7 @@ export function isValidTriggerPosition(
122
125
  return isInlineWhitespace(prevChar)
123
126
  }
124
127
 
125
- // ---------------------------------------------------------------------------
126
128
  // Trigger detection
127
- // ---------------------------------------------------------------------------
128
129
 
129
130
  /**
130
131
  * Scans backwards from the cursor position to detect if the user is
@@ -136,12 +137,10 @@ export function isValidTriggerPosition(
136
137
  * @param cursorPos - The cursor position (character offset from start)
137
138
  * @param triggers - Available trigger configurations
138
139
  */
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
140
+ export function detectActiveTrigger(text: string, cursorPos: number, triggers: TriggerConfig[]): ActiveTrigger | null {
141
+ if (!text || cursorPos === 0 || triggers.length === 0) {
142
+ return null
143
+ }
145
144
 
146
145
  const triggerByChar = buildTriggerCharMap(triggers)
147
146
 
@@ -169,7 +168,6 @@ export function detectActiveTrigger(
169
168
  return null
170
169
  }
171
170
 
172
- // Check if this character is a trigger character
173
171
  const matchingTrigger = triggerByChar.get(char)
174
172
  if (matchingTrigger && isValidTriggerPosition(text, i, matchingTrigger.position)) {
175
173
  return {
@@ -183,9 +181,7 @@ export function detectActiveTrigger(
183
181
  return null
184
182
  }
185
183
 
186
- // ---------------------------------------------------------------------------
187
184
  // Chip resolution
188
- // ---------------------------------------------------------------------------
189
185
 
190
186
  /**
191
187
  * Resolves an active trigger into a chip within the segments array.
@@ -204,7 +200,6 @@ export function resolveChip(
204
200
  const triggerStart = activeTrigger.startOffset
205
201
  const triggerEnd = triggerStart + 1 + activeTrigger.query.length // +1 for trigger char
206
202
 
207
- // Build the new segments by mapping plain text positions back to segment boundaries
208
203
  const newSegments: Segment[] = []
209
204
  let offset = 0
210
205
 
@@ -261,7 +256,6 @@ export function resolveChip(
261
256
  }
262
257
  }
263
258
 
264
- // Merge adjacent text segments
265
259
  const merged = mergeAdjacentTextSegments(newSegments)
266
260
 
267
261
  // Cursor should be placed after the chip + trailing space.
@@ -273,11 +267,7 @@ export function resolveChip(
273
267
  runningOffset += seg.text.length
274
268
  } else {
275
269
  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
- ) {
270
+ if (seg.value === chip.value && seg.displayText === chip.displayText && seg.trigger === activeTrigger.config.char) {
281
271
  lastChipEndOffset = runningOffset
282
272
  }
283
273
  }
@@ -315,7 +305,9 @@ export function resolveText(
315
305
  const chipEnd = offset + `${seg.trigger}${seg.displayText}`.length
316
306
  // A trigger range can never overlap a chip — chips are atomic — so a chip
317
307
  // is either wholly before or wholly after, and is kept either way.
318
- if (chipEnd <= triggerStart || offset >= triggerEnd) newSegments.push(seg)
308
+ if (chipEnd <= triggerStart || offset >= triggerEnd) {
309
+ newSegments.push(seg)
310
+ }
319
311
  offset = chipEnd
320
312
  continue
321
313
  }
@@ -326,9 +318,13 @@ export function resolveText(
326
318
  } else {
327
319
  const before = seg.text.slice(0, Math.max(0, triggerStart - textStart))
328
320
  const after = seg.text.slice(Math.min(seg.text.length, triggerEnd - textStart))
329
- if (before) newSegments.push({ type: 'text', text: before })
321
+ if (before) {
322
+ newSegments.push({ type: 'text', text: before })
323
+ }
330
324
  newSegments.push({ type: 'text', text })
331
- if (after) newSegments.push({ type: 'text', text: after })
325
+ if (after) {
326
+ newSegments.push({ type: 'text', text: after })
327
+ }
332
328
  }
333
329
  offset = textEnd
334
330
  }
@@ -337,13 +333,13 @@ export function resolveText(
337
333
  // Clamp: a trigger detected against a stale document could otherwise leave the
338
334
  // caret past the end, which renders as no caret at all.
339
335
  const total = segmentsToPlainText(merged).length
340
- if (cursorOffset > total) cursorOffset = total
336
+ if (cursorOffset > total) {
337
+ cursorOffset = total
338
+ }
341
339
  return { segments: merged, cursorOffset }
342
340
  }
343
341
 
344
- // ---------------------------------------------------------------------------
345
342
  // Chip removal
346
- // ---------------------------------------------------------------------------
347
343
 
348
344
  /**
349
345
  * Removes a chip at the given segment index and merges adjacent text segments.
@@ -353,8 +349,12 @@ export function resolveText(
353
349
  * @returns New segments array with the chip removed
354
350
  */
355
351
  export function removeChipAtIndex(segments: Segment[], index: number): Segment[] {
356
- if (index < 0 || index >= segments.length) return segments
357
- if (segments[index].type !== 'chip') return segments
352
+ if (index < 0 || index >= segments.length) {
353
+ return segments
354
+ }
355
+ if (segments[index].type !== 'chip') {
356
+ return segments
357
+ }
358
358
 
359
359
  const result = [...segments.slice(0, index), ...segments.slice(index + 1)]
360
360
  return mergeAdjacentTextSegments(result)
@@ -368,26 +368,21 @@ export function removeChipAtIndex(segments: Segment[], index: number): Segment[]
368
368
  * @param index - The segment index to revert
369
369
  * @returns New segments with the chip replaced by text, or null if not applicable
370
370
  */
371
- export function revertChipAtIndex(
372
- segments: Segment[],
373
- index: number,
374
- ): { segments: Segment[]; revertedText: string } | null {
375
- if (index < 0 || index >= segments.length) return null
371
+ export function revertChipAtIndex(segments: Segment[], index: number): { segments: Segment[]; revertedText: string } | null {
372
+ if (index < 0 || index >= segments.length) {
373
+ return null
374
+ }
376
375
  const seg = segments[index]
377
- if (seg.type !== 'chip' || !seg.autoResolved) return null
376
+ if (seg.type !== 'chip' || !seg.autoResolved) {
377
+ return null
378
+ }
378
379
 
379
380
  const revertedText = `${seg.trigger}${seg.displayText}`
380
- const result = [
381
- ...segments.slice(0, index),
382
- { type: 'text' as const, text: revertedText },
383
- ...segments.slice(index + 1),
384
- ]
381
+ const result = [...segments.slice(0, index), { type: 'text' as const, text: revertedText }, ...segments.slice(index + 1)]
385
382
  return { segments: mergeAdjacentTextSegments(result), revertedText }
386
383
  }
387
384
 
388
- // ---------------------------------------------------------------------------
389
385
  // Paste: resolve trigger patterns in segments
390
- // ---------------------------------------------------------------------------
391
386
 
392
387
  /**
393
388
  * Scans text segments for trigger patterns and auto-resolves them into chips.
@@ -397,12 +392,11 @@ export function revertChipAtIndex(
397
392
  * whitespace, or after a newline. This avoids false positives like email
398
393
  * addresses (user@example.com).
399
394
  */
400
- export function resolveTriggersInSegments(
401
- segments: Segment[],
402
- triggers: TriggerConfig[],
403
- ): Segment[] {
395
+ export function resolveTriggersInSegments(segments: Segment[], triggers: TriggerConfig[]): Segment[] {
404
396
  const autoResolveTriggers = triggers.filter((t) => t.resolveOnSpace)
405
- if (autoResolveTriggers.length === 0) return segments
397
+ if (autoResolveTriggers.length === 0) {
398
+ return segments
399
+ }
406
400
 
407
401
  const triggerByChar = buildTriggerCharMap(autoResolveTriggers)
408
402
  const result: Segment[] = []
@@ -425,11 +419,10 @@ export function resolveTriggersInSegments(
425
419
  * A trigger pattern is: (start-of-string | whitespace) + trigger_char + word_chars
426
420
  * followed by whitespace or end-of-string.
427
421
  */
428
- function splitTextByTriggerPatterns(
429
- text: string,
430
- triggerByChar: Map<string, TriggerConfig>,
431
- ): Segment[] {
432
- if (!text) return []
422
+ function splitTextByTriggerPatterns(text: string, triggerByChar: Map<string, TriggerConfig>): Segment[] {
423
+ if (!text) {
424
+ return []
425
+ }
433
426
 
434
427
  const segments: Segment[] = []
435
428
  let i = 0
@@ -479,9 +472,7 @@ function splitTextByTriggerPatterns(
479
472
  return segments
480
473
  }
481
474
 
482
- // ---------------------------------------------------------------------------
483
475
  // Text range replacement
484
- // ---------------------------------------------------------------------------
485
476
 
486
477
  /**
487
478
  * Replaces a range of plain text within the segments array.
@@ -493,12 +484,7 @@ function splitTextByTriggerPatterns(
493
484
  * @param replacement - The replacement text
494
485
  * @returns New segments array with the replacement applied
495
486
  */
496
- export function replaceTextRange(
497
- segments: Segment[],
498
- start: number,
499
- end: number,
500
- replacement: string,
501
- ): Segment[] {
487
+ export function replaceTextRange(segments: Segment[], start: number, end: number, replacement: string): Segment[] {
502
488
  const newSegments: Segment[] = []
503
489
  let offset = 0
504
490
  let inserted = false
@@ -518,13 +504,11 @@ export function replaceTextRange(
518
504
  if (chipEnd <= start || chipStart >= end) {
519
505
  newSegments.push(seg)
520
506
  }
521
- // Chips within the range are removed
522
507
  offset = chipEnd
523
508
  } else {
524
509
  const textStart = offset
525
510
  const textEnd = offset + seg.text.length
526
511
 
527
- // Check if this segment contains the insertion/replacement point
528
512
  const isBefore = start === end ? textEnd < start : textEnd <= start
529
513
  const isAfter = start === end ? textStart > end : textStart >= end
530
514
 
@@ -564,9 +548,7 @@ export function replaceTextRange(
564
548
  return mergeAdjacentTextSegments(newSegments)
565
549
  }
566
550
 
567
- // ---------------------------------------------------------------------------
568
551
  // Markdown formatting shortcuts
569
- // ---------------------------------------------------------------------------
570
552
 
571
553
  /**
572
554
  * Toggles markdown wrap markers around a selected text range.
@@ -585,28 +567,24 @@ export function toggleMarkdownWrap(
585
567
  selectionEnd: number,
586
568
  marker: string,
587
569
  ): { segments: Segment[]; selectionStart: number; selectionEnd: number } | null {
588
- if (selectionStart === selectionEnd) return null
570
+ if (selectionStart === selectionEnd) {
571
+ return null
572
+ }
589
573
 
590
574
  const plainText = segmentsToPlainText(segments)
591
575
  const markerLen = marker.length
592
576
 
593
- // Check if already wrapped
594
- const hasOpeningMarker =
595
- selectionStart >= markerLen &&
596
- plainText.slice(selectionStart - markerLen, selectionStart) === marker
577
+ const hasOpeningMarker = selectionStart >= markerLen && plainText.slice(selectionStart - markerLen, selectionStart) === marker
597
578
  const hasClosingMarker =
598
- selectionEnd + markerLen <= plainText.length &&
599
- plainText.slice(selectionEnd, selectionEnd + markerLen) === marker
579
+ selectionEnd + markerLen <= plainText.length && plainText.slice(selectionEnd, selectionEnd + markerLen) === marker
600
580
 
601
581
  let isWrapped = hasOpeningMarker && hasClosingMarker
602
582
 
603
583
  // For single-char markers (e.g., '*'), ensure we're not matching
604
584
  // inside a multi-char marker (e.g., '**')
605
585
  if (isWrapped && markerLen === 1) {
606
- const charBeforeOpening =
607
- selectionStart > markerLen ? plainText[selectionStart - markerLen - 1] : ''
608
- const charAfterClosing =
609
- selectionEnd + markerLen < plainText.length ? plainText[selectionEnd + markerLen] : ''
586
+ const charBeforeOpening = selectionStart > markerLen ? plainText[selectionStart - markerLen - 1] : ''
587
+ const charAfterClosing = selectionEnd + markerLen < plainText.length ? plainText[selectionEnd + markerLen] : ''
610
588
  if (charBeforeOpening === marker || charAfterClosing === marker) {
611
589
  isWrapped = false
612
590
  }
@@ -615,12 +593,7 @@ export function toggleMarkdownWrap(
615
593
  if (isWrapped) {
616
594
  // Unwrap: remove closing marker first (preserves start offsets), then opening
617
595
  const afterClosing = replaceTextRange(segments, selectionEnd, selectionEnd + markerLen, '')
618
- const afterOpening = replaceTextRange(
619
- afterClosing,
620
- selectionStart - markerLen,
621
- selectionStart,
622
- '',
623
- )
596
+ const afterOpening = replaceTextRange(afterClosing, selectionStart - markerLen, selectionStart, '')
624
597
  return {
625
598
  segments: afterOpening,
626
599
  selectionStart: selectionStart - markerLen,
@@ -638,9 +611,7 @@ export function toggleMarkdownWrap(
638
611
  }
639
612
  }
640
613
 
641
- // ---------------------------------------------------------------------------
642
614
  // Inline markdown parsing
643
- // ---------------------------------------------------------------------------
644
615
 
645
616
  export type MarkdownToken =
646
617
  | { type: 'plain'; text: string }
@@ -654,7 +625,9 @@ export type MarkdownToken =
654
625
  * Does NOT handle block-level markdown (lists, headings, etc.).
655
626
  */
656
627
  export function parseInlineMarkdown(text: string): MarkdownToken[] {
657
- if (!text) return []
628
+ if (!text) {
629
+ return []
630
+ }
658
631
 
659
632
  const tokens: MarkdownToken[] = []
660
633
  // Regex patterns for inline markdown elements:
@@ -668,7 +641,6 @@ export function parseInlineMarkdown(text: string): MarkdownToken[] {
668
641
  let match: RegExpExecArray | null
669
642
 
670
643
  while ((match = pattern.exec(text)) !== null) {
671
- // Add any plain text before this match
672
644
  if (match.index > lastIndex) {
673
645
  tokens.push({ type: 'plain', text: text.slice(lastIndex, match.index) })
674
646
  }
@@ -690,7 +662,6 @@ export function parseInlineMarkdown(text: string): MarkdownToken[] {
690
662
  lastIndex = match.index + match[0].length
691
663
  }
692
664
 
693
- // Add any remaining plain text
694
665
  if (lastIndex < text.length) {
695
666
  tokens.push({ type: 'plain', text: text.slice(lastIndex) })
696
667
  }
@@ -698,9 +669,7 @@ export function parseInlineMarkdown(text: string): MarkdownToken[] {
698
669
  return tokens
699
670
  }
700
671
 
701
- // ---------------------------------------------------------------------------
702
672
  // Segment comparison
703
- // ---------------------------------------------------------------------------
704
673
 
705
674
  /**
706
675
  * Shallow equality check for two segment arrays.
@@ -708,15 +677,23 @@ export function parseInlineMarkdown(text: string): MarkdownToken[] {
708
677
  * Avoids JSON.stringify overhead for the common case.
709
678
  */
710
679
  export function segmentsEqual(a: Segment[], b: Segment[]): boolean {
711
- if (a === b) return true
712
- if (a.length !== b.length) return false
680
+ if (a === b) {
681
+ return true
682
+ }
683
+ if (a.length !== b.length) {
684
+ return false
685
+ }
713
686
 
714
687
  for (let i = 0; i < a.length; i++) {
715
688
  const sa = a[i]
716
689
  const sb = b[i]
717
- if (sa.type !== sb.type) return false
690
+ if (sa.type !== sb.type) {
691
+ return false
692
+ }
718
693
  if (sa.type === 'text') {
719
- if (sb.type !== 'text' || sa.text !== sb.text) return false
694
+ if (sb.type !== 'text' || sa.text !== sb.text) {
695
+ return false
696
+ }
720
697
  } else {
721
698
  if (
722
699
  sb.type !== 'chip' ||
@@ -724,16 +701,23 @@ export function segmentsEqual(a: Segment[], b: Segment[]): boolean {
724
701
  sa.value !== sb.value ||
725
702
  sa.displayText !== sb.displayText ||
726
703
  sa.autoResolved !== sb.autoResolved
727
- )
704
+ ) {
728
705
  return false
706
+ }
729
707
  }
730
708
  }
731
709
  return true
732
710
  }
733
711
 
734
- // ---------------------------------------------------------------------------
735
- // Helpers
736
- // ---------------------------------------------------------------------------
712
+ /**
713
+ * Content equality regardless of how text is chunked into segments: adjacent
714
+ * text segments are merged (and empty ones dropped) on both sides before
715
+ * comparing, so a DOM read split by decorations or per-`<br>` newlines equals
716
+ * the merged model that rendered it.
717
+ */
718
+ export function segmentsContentEqual(a: Segment[], b: Segment[]): boolean {
719
+ return segmentsEqual(mergeAdjacentTextSegments(a), mergeAdjacentTextSegments(b))
720
+ }
737
721
 
738
722
  /**
739
723
  * Merges adjacent text segments into single text segments.
@@ -743,11 +727,12 @@ export function mergeAdjacentTextSegments(segments: Segment[]): Segment[] {
743
727
  const result: Segment[] = []
744
728
 
745
729
  for (const seg of segments) {
746
- if (seg.type === 'text' && seg.text === '') continue
730
+ if (seg.type === 'text' && seg.text === '') {
731
+ continue
732
+ }
747
733
 
748
734
  const last = result[result.length - 1]
749
735
  if (seg.type === 'text' && last?.type === 'text') {
750
- // Merge with previous text segment
751
736
  result[result.length - 1] = { type: 'text', text: last.text + seg.text }
752
737
  } else {
753
738
  result.push(seg)