dsh-explorer 0.0.0-stage → 0.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.
@@ -0,0 +1,635 @@
1
+ export interface DraftCaret {
2
+ start: number
3
+ end: number
4
+ }
5
+
6
+ let lastKnownSessionId = ''
7
+ let trackedCaret: { start: number; end: number; draft?: string; timestamp: number } | null = null
8
+
9
+ export function setLastKnownSessionId(id?: string): void {
10
+ if (id) lastKnownSessionId = id
11
+ }
12
+
13
+ export function safeService(ctx: any, name: string): any {
14
+ try {
15
+ return ctx?.get ? ctx.get(name) : ctx?.reflect?.get ? ctx.reflect.get(name, false) : ctx?.[name]
16
+ } catch {
17
+ try {
18
+ return ctx?.reflect?.get?.(name, false)
19
+ } catch {
20
+ return undefined
21
+ }
22
+ }
23
+ }
24
+
25
+ export function resolveActiveSessionId(ctx: any, explicitId?: string): string {
26
+ if (explicitId) {
27
+ lastKnownSessionId = explicitId
28
+ return explicitId
29
+ }
30
+ if (lastKnownSessionId) return lastKnownSessionId
31
+
32
+ try {
33
+ const sessions = safeService(ctx, 'sessions')
34
+ if (sessions) {
35
+ const snapshot = sessions.list?.getSnapshot?.()
36
+ if (snapshot?.byId) {
37
+ for (const [id, row] of Object.entries<any>(snapshot.byId)) {
38
+ if ((row?.retainedBy?.mainView ?? 0) > 0) {
39
+ lastKnownSessionId = id
40
+ return id
41
+ }
42
+ }
43
+ if (snapshot.activeId) {
44
+ lastKnownSessionId = snapshot.activeId
45
+ return snapshot.activeId
46
+ }
47
+ if (Array.isArray(snapshot.ids) && snapshot.ids.length > 0) {
48
+ const id = snapshot.ids[snapshot.ids.length - 1]
49
+ lastKnownSessionId = id
50
+ return id
51
+ }
52
+ const keys = Object.keys(snapshot.byId)
53
+ if (keys.length > 0) {
54
+ const id = keys[keys.length - 1]
55
+ lastKnownSessionId = id
56
+ return id
57
+ }
58
+ }
59
+ if (sessions.active?.id) {
60
+ lastKnownSessionId = sessions.active.id
61
+ return sessions.active.id
62
+ }
63
+ if (sessions.current?.id) {
64
+ lastKnownSessionId = sessions.current.id
65
+ return sessions.current.id
66
+ }
67
+ }
68
+ } catch {
69
+ // Ignore
70
+ }
71
+ return ''
72
+ }
73
+
74
+ /**
75
+ * Locate DSH's native Lexical composer element.
76
+ * DSH marks the composer contenteditable with [data-composer-input="true"].
77
+ */
78
+ export function findComposerElement(): HTMLElement | null {
79
+ if (typeof document === 'undefined') return null
80
+
81
+ // 1. Direct DSH Lexical composer attribute
82
+ const composerInput =
83
+ document.querySelector<HTMLElement>('[data-composer-input="true"]') ??
84
+ document.querySelector<HTMLElement>('[data-composer-input]')
85
+ if (composerInput) return composerInput
86
+
87
+ // 2. Lexical composer inside conversation slot
88
+ const slot =
89
+ document.querySelector('[data-slot="conversation"]') ??
90
+ document.querySelector('[data-slot*="conversation"]') ??
91
+ document.body
92
+ const ce =
93
+ slot.querySelector<HTMLElement>('[contenteditable="true"][role="textbox"]') ??
94
+ slot.querySelector<HTMLElement>('[contenteditable="true"]')
95
+ if (ce) return ce
96
+
97
+ // 3. Fallback textarea
98
+ return (
99
+ document.querySelector<HTMLTextAreaElement>('textarea[data-phase]') ??
100
+ document.querySelector<HTMLTextAreaElement>('textarea') ??
101
+ document.querySelector<HTMLElement>('[contenteditable="true"]') ??
102
+ null
103
+ )
104
+ }
105
+
106
+ /**
107
+ * Continuously track user selection/caret in composer.
108
+ */
109
+ export function recordLiveCaret(): void {
110
+ if (typeof document === 'undefined') return
111
+ const el = findComposerElement()
112
+ if (!el) return
113
+
114
+ if (el.tagName === 'TEXTAREA' || el.tagName === 'INPUT') {
115
+ const ta = el as HTMLTextAreaElement
116
+ const start = ta.selectionStart
117
+ const end = ta.selectionEnd
118
+ if (typeof start === 'number' && typeof end === 'number' && Number.isFinite(start) && Number.isFinite(end)) {
119
+ trackedCaret = {
120
+ start: Math.max(0, Math.min(start, ta.value.length)),
121
+ end: Math.max(start, Math.min(end, ta.value.length)),
122
+ draft: ta.value,
123
+ timestamp: Date.now(),
124
+ }
125
+ }
126
+ } else if (el.isContentEditable) {
127
+ const sel = window.getSelection()
128
+ if (sel && sel.rangeCount > 0 && el.contains(sel.anchorNode)) {
129
+ const range = sel.getRangeAt(0)
130
+ const preRange = range.cloneRange()
131
+ preRange.selectNodeContents(el)
132
+ preRange.setEnd(range.startContainer, range.startOffset)
133
+ const start = preRange.toString().length
134
+ const length = range.toString().length
135
+ trackedCaret = {
136
+ start,
137
+ end: start + length,
138
+ draft: el.innerText ?? el.textContent ?? '',
139
+ timestamp: Date.now(),
140
+ }
141
+ }
142
+ }
143
+ }
144
+
145
+ let caretTrackerAttached = false
146
+ export function setupCaretTracker(): () => void {
147
+ if (caretTrackerAttached || typeof document === 'undefined') return () => {}
148
+ caretTrackerAttached = true
149
+
150
+ const handleSelection = () => recordLiveCaret()
151
+ document.addEventListener('selectionchange', handleSelection, { passive: true })
152
+ document.addEventListener('keyup', handleSelection, { passive: true })
153
+ document.addEventListener('mouseup', handleSelection, { passive: true })
154
+ document.addEventListener('input', handleSelection, { passive: true })
155
+ document.addEventListener('focusin', handleSelection, { passive: true })
156
+
157
+ return () => {
158
+ document.removeEventListener('selectionchange', handleSelection)
159
+ document.removeEventListener('keyup', handleSelection)
160
+ document.removeEventListener('mouseup', handleSelection)
161
+ document.removeEventListener('input', handleSelection)
162
+ document.removeEventListener('focusin', handleSelection)
163
+ caretTrackerAttached = false
164
+ }
165
+ }
166
+
167
+ /**
168
+ * Snap caret so it never cuts inside a word token (e.g. 'per|lu' -> 'perlu|').
169
+ */
170
+ export function snapCaretToWordBoundary(draft: string, caret: DraftCaret | null): DraftCaret | null {
171
+ if (!caret || draft.length === 0) return caret
172
+ let { start, end } = caret
173
+
174
+ const isWordChar = (ch: string | undefined): boolean => {
175
+ return Boolean(ch && !/[\s\r\n\t]/.test(ch))
176
+ }
177
+
178
+ if (start === end && start > 0 && start < draft.length) {
179
+ const prevChar = draft[start - 1]
180
+ const nextChar = draft[start]
181
+ if (isWordChar(prevChar) && isWordChar(nextChar)) {
182
+ while (start < draft.length && isWordChar(draft[start])) {
183
+ start++
184
+ }
185
+ end = start
186
+ }
187
+ }
188
+
189
+ return { start, end }
190
+ }
191
+
192
+ export function probeComposerCaret(draft: string, targetPoint?: { x: number; y: number }): DraftCaret | null {
193
+ const el = findComposerElement()
194
+
195
+ // 1. Direct read from focused/active textarea
196
+ if (el && (el.tagName === 'TEXTAREA' || el.tagName === 'INPUT')) {
197
+ const ta = el as HTMLTextAreaElement
198
+ if (!ta.disabled && !ta.readOnly) {
199
+ const start = ta.selectionStart
200
+ const end = ta.selectionEnd
201
+ if (
202
+ typeof start === 'number' &&
203
+ typeof end === 'number' &&
204
+ Number.isFinite(start) &&
205
+ Number.isFinite(end) &&
206
+ (document.activeElement === ta || ta.value === draft)
207
+ ) {
208
+ const clampedStart = Math.max(0, Math.min(start, draft.length))
209
+ const clampedEnd = Math.max(clampedStart, Math.min(end, draft.length))
210
+ trackedCaret = { start: clampedStart, end: clampedEnd, draft, timestamp: Date.now() }
211
+ return { start: clampedStart, end: clampedEnd }
212
+ }
213
+ }
214
+ }
215
+
216
+ // 2. Fallback to continuously tracked live caret
217
+ if (trackedCaret) {
218
+ const clampedStart = Math.max(0, Math.min(trackedCaret.start, draft.length))
219
+ const clampedEnd = Math.max(clampedStart, Math.min(trackedCaret.end, draft.length))
220
+ return { start: clampedStart, end: clampedEnd }
221
+ }
222
+
223
+ return null
224
+ }
225
+
226
+ export function placeComposerCaret(expectedDraft: string, caretIndex: number): void {
227
+ let remaining = 3
228
+ const check = () => {
229
+ remaining -= 1
230
+ const el = findComposerElement()
231
+ if (!el) return
232
+ if (el.tagName === 'TEXTAREA' || el.tagName === 'INPUT') {
233
+ const textarea = el as HTMLTextAreaElement
234
+ if (textarea.disabled || textarea.readOnly) return
235
+ const clamped = Math.max(0, Math.min(caretIndex, textarea.value.length))
236
+ textarea.focus()
237
+ textarea.setSelectionRange(clamped, clamped)
238
+ trackedCaret = { start: clamped, end: clamped, draft: textarea.value, timestamp: Date.now() }
239
+ return
240
+ } else if (el.isContentEditable) {
241
+ el.focus()
242
+ trackedCaret = { start: caretIndex, end: caretIndex, draft: expectedDraft, timestamp: Date.now() }
243
+ return
244
+ }
245
+ if (remaining > 0) {
246
+ if (typeof requestAnimationFrame === 'function') requestAnimationFrame(check)
247
+ else setTimeout(check, 16)
248
+ }
249
+ }
250
+ if (typeof requestAnimationFrame === 'function') requestAnimationFrame(check)
251
+ else setTimeout(check, 16)
252
+ }
253
+
254
+ /**
255
+ * Splice text into draft with strict whitespace and word-boundary safety.
256
+ */
257
+ export function spliceInsert(
258
+ draft: string,
259
+ text: string,
260
+ rawCaret: DraftCaret | null,
261
+ ): { draft: string; caretAfter: number } {
262
+ const caret = snapCaretToWordBoundary(draft, rawCaret)
263
+ if (!caret || draft === '') {
264
+ const next = draft.trim() === '' ? text : `${draft}${/\s$/.test(draft) ? '' : ' '}${text}`
265
+ return { draft: next, caretAfter: next.length }
266
+ }
267
+
268
+ const prefix = draft.slice(0, caret.start)
269
+ const suffix = draft.slice(caret.end)
270
+
271
+ // Enforce single space separation on left
272
+ const left = prefix === '' || /\s$/.test(prefix) ? '' : ' '
273
+
274
+ // Enforce single space separation on right
275
+ const right = suffix === '' || /^\s/.test(suffix) || /\s$/.test(text) ? '' : ' '
276
+
277
+ const next = `${prefix}${left}${text}${right}${suffix}`
278
+ const caretAfter = prefix.length + left.length + text.length
279
+
280
+ return {
281
+ draft: next,
282
+ caretAfter,
283
+ }
284
+ }
285
+
286
+ export function mentionForPath(relativePath: string, isDir: boolean): string {
287
+ const norm = relativePath.replace(/[\\/]+$/, '').replace(/\\/g, '/')
288
+ const target = isDir ? `${norm}/` : norm
289
+ return /\s/.test(target) ? `@"${target}"` : `@${target}`
290
+ }
291
+
292
+ export function baseName(path: string): string {
293
+ const at = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\'))
294
+ return at === -1 ? path : path.slice(at + 1)
295
+ }
296
+
297
+ /**
298
+ * Main insertion function for DSH chat composer.
299
+ * Inserts native structured ReferenceChip nodes with strict single-execution
300
+ * protection so mentions never duplicate, never merge, and always format cleanly.
301
+ */
302
+ export function insertMentionToComposer(
303
+ ctx: any,
304
+ sessionId: string,
305
+ relativePath: string,
306
+ isDir: boolean,
307
+ targetPoint?: { x: number; y: number },
308
+ ): boolean {
309
+ try {
310
+ const mention = mentionForPath(relativePath, isDir)
311
+ const name = baseName(relativePath)
312
+ const label = isDir ? `${name}/` : name
313
+ const appearance = isDir ? 'folder' : 'file'
314
+
315
+ // If drop coordinate provided, set Lexical selection to the exact drop point first
316
+ if (targetPoint && typeof document !== 'undefined') {
317
+ const el = findComposerElement()
318
+ if (el) {
319
+ let range: Range | null = null
320
+ if (typeof (document as any).caretRangeFromPoint === 'function') {
321
+ range = (document as any).caretRangeFromPoint(targetPoint.x, targetPoint.y)
322
+ } else if (typeof (document as any).caretPositionFromPoint === 'function') {
323
+ const pos = (document as any).caretPositionFromPoint(targetPoint.x, targetPoint.y)
324
+ if (pos && pos.offsetNode) {
325
+ range = document.createRange()
326
+ range.setStart(pos.offsetNode, pos.offset)
327
+ range.collapse(true)
328
+ }
329
+ }
330
+ if (range && el.contains(range.startContainer)) {
331
+ const sel = window.getSelection()
332
+ sel?.removeAllRanges()
333
+ sel?.addRange(range)
334
+ el.focus({ preventScroll: true })
335
+ }
336
+ }
337
+ }
338
+
339
+ const sessions = safeService(ctx, 'sessions')
340
+ const targetSessionId = resolveActiveSessionId(ctx, sessionId)
341
+ const actx = targetSessionId && sessions?.scope ? sessions.scope(targetSessionId) : undefined
342
+ const conversation = safeService(ctx, 'conversation')
343
+
344
+ // 1. Primary path: Native DSH Lexical structured reference chip (supports both file & folder)
345
+ if (conversation && actx) {
346
+ const shell = conversation.input?.for(actx)
347
+ if (shell) {
348
+ // Method A: Direct native batch reference insertion on draftEditor
349
+ // This is DSH's internal native routine which automatically guarantees single-space separation
350
+ // on both sides without manual cursor manipulation or span races.
351
+ if (shell.draftEditor && typeof shell.draftEditor.insertFileReferences === 'function') {
352
+ try {
353
+ const revBefore = shell.rev ?? shell.state?.getSnapshot?.()?.draftRev ?? 0
354
+ const applied = shell.draftEditor.insertFileReferences([
355
+ {
356
+ source: 'reference',
357
+ ref: mention,
358
+ label,
359
+ appearance,
360
+ clipboardText: mention,
361
+ },
362
+ ])
363
+ const revAfter = shell.rev ?? shell.state?.getSnapshot?.()?.draftRev ?? 0
364
+ if (applied || revAfter !== revBefore) {
365
+ shell.focus?.()
366
+ return true
367
+ }
368
+ } catch (err) {
369
+ console.warn('[dsh-explorer] draftEditor.insertFileReferences failed, trying actx.bail:', err)
370
+ }
371
+ }
372
+
373
+ // Method B: Event-driven reference chip insertion with detectText-aligned whitespace check
374
+ let span = shell.actions?.captureInsertion?.() ?? shell.captureInsertion?.()
375
+ if (span && span.draftRev !== undefined) {
376
+ const detectText = shell.projection?.detectText ?? ''
377
+ let charBefore = span.start > 0 ? (detectText[span.start - 1] ?? '') : ''
378
+
379
+ if (!charBefore) {
380
+ try {
381
+ const sel = window.getSelection()
382
+ if (sel && sel.anchorNode) {
383
+ const text = sel.anchorNode.textContent ?? ''
384
+ const offset = sel.anchorOffset
385
+ if (offset > 0) charBefore = text[offset - 1] ?? ''
386
+ }
387
+ } catch {}
388
+ }
389
+ if (!charBefore) {
390
+ const draftText = shell.state?.getSnapshot?.()?.draft ?? ''
391
+ if (span.start > 0 && span.start <= draftText.length) {
392
+ charBefore = draftText[span.start - 1] ?? ''
393
+ }
394
+ }
395
+
396
+ // If preceded by a non-whitespace character, insert a single space first so the chip doesn't merge
397
+ if (charBefore && !/\s/.test(charBefore)) {
398
+ if (typeof shell.actions?.insertText === 'function') {
399
+ try { shell.actions.insertText(' ', span) } catch {}
400
+ } else if (typeof shell.insertText === 'function') {
401
+ try { shell.insertText(' ', span) } catch {}
402
+ }
403
+ const updated = shell.actions?.captureInsertion?.() ?? shell.captureInsertion?.()
404
+ if (updated && updated.draftRev !== undefined) {
405
+ span = updated
406
+ }
407
+ }
408
+
409
+ const revBefore = shell.rev ?? shell.state?.getSnapshot?.()?.draftRev ?? 0
410
+ let applied = false
411
+
412
+ // Try actx.bail first (Cordis bail returns the listener's return value!)
413
+ try {
414
+ if (typeof actx.bail === 'function') {
415
+ applied = Boolean(
416
+ actx.bail(actx, 'slash/input-insert-reference', {
417
+ reference: {
418
+ source: 'reference',
419
+ ref: mention,
420
+ label,
421
+ appearance,
422
+ clipboardText: mention,
423
+ },
424
+ span,
425
+ })
426
+ )
427
+ }
428
+ } catch {}
429
+
430
+ if (!applied) {
431
+ try {
432
+ actx.emit?.('slash/input-insert-reference', {
433
+ reference: {
434
+ source: 'reference',
435
+ ref: mention,
436
+ label,
437
+ appearance,
438
+ clipboardText: mention,
439
+ },
440
+ span,
441
+ })
442
+ } catch {}
443
+ }
444
+
445
+ const revAfter = shell.rev ?? shell.state?.getSnapshot?.()?.draftRev ?? 0
446
+
447
+ // If chip insertion applied or rev bumped, RETURN IMMEDIATELY (no fallthrough duplicate!)
448
+ if (applied || revAfter !== revBefore) {
449
+ shell.focus?.()
450
+ return true
451
+ }
452
+
453
+ // If chip node insertion not handled, insert plain reference text via Lexical replaceText
454
+ let leadSpace = ''
455
+ if (charBefore && !/\s/.test(charBefore)) leadSpace = ' '
456
+ const textToInsert = `${leadSpace}${mention} `
457
+ if (typeof shell.actions?.insertText === 'function') {
458
+ try {
459
+ if (shell.actions.insertText(textToInsert, span)) {
460
+ shell.focus?.()
461
+ return true
462
+ }
463
+ } catch {}
464
+ }
465
+ if (typeof shell.insertText === 'function') {
466
+ try {
467
+ if (shell.insertText(textToInsert, span)) {
468
+ shell.focus?.()
469
+ return true
470
+ }
471
+ } catch {}
472
+ }
473
+ }
474
+ }
475
+ }
476
+
477
+ // 2. DOM fallback on Lexical contenteditable
478
+ const el = findComposerElement()
479
+ if (el) {
480
+ if (el.isContentEditable) {
481
+ el.focus()
482
+ let needLeadingSpace = false
483
+ try {
484
+ const sel = window.getSelection()
485
+ if (sel && sel.rangeCount > 0) {
486
+ const r = sel.getRangeAt(0)
487
+ if (r.startContainer.nodeType === Node.TEXT_NODE) {
488
+ const text = r.startContainer.textContent ?? ''
489
+ if (r.startOffset > 0 && !/\s/.test(text[r.startOffset - 1])) {
490
+ needLeadingSpace = true
491
+ }
492
+ } else if (r.startContainer.previousSibling) {
493
+ const text = r.startContainer.previousSibling.textContent ?? ''
494
+ if (text && !/\s/.test(text.slice(-1))) {
495
+ needLeadingSpace = true
496
+ }
497
+ }
498
+ }
499
+ } catch {}
500
+
501
+ const textToInsert = `${needLeadingSpace ? ' ' : ''}${mention} `
502
+
503
+ try {
504
+ if (document.execCommand('insertText', false, textToInsert)) {
505
+ recordLiveCaret()
506
+ return true
507
+ }
508
+ } catch {}
509
+
510
+ try {
511
+ const sel = window.getSelection()
512
+ if (sel && sel.rangeCount > 0) {
513
+ const range = sel.getRangeAt(0)
514
+ range.deleteContents()
515
+ const textNode = document.createTextNode(textToInsert)
516
+ range.insertNode(textNode)
517
+ range.collapse(false)
518
+ sel.removeAllRanges()
519
+ sel.addRange(range)
520
+ el.dispatchEvent(new Event('input', { bubbles: true }))
521
+ recordLiveCaret()
522
+ return true
523
+ }
524
+ } catch {}
525
+ }
526
+
527
+ if (el.tagName === 'TEXTAREA' || el.tagName === 'INPUT') {
528
+ const textarea = el as HTMLTextAreaElement
529
+ const current = textarea.value
530
+ const rawCaret = probeComposerCaret(current, targetPoint) ?? {
531
+ start: textarea.selectionStart ?? current.length,
532
+ end: textarea.selectionEnd ?? current.length,
533
+ }
534
+ const { draft: next, caretAfter } = spliceInsert(current, `${mention} `, rawCaret)
535
+ textarea.value = next
536
+ textarea.dispatchEvent(new Event('input', { bubbles: true }))
537
+ placeComposerCaret(next, caretAfter)
538
+ return true
539
+ }
540
+ }
541
+
542
+ // 3. Last-resort fallback: setDraft
543
+ if (conversation && actx) {
544
+ const shell = conversation.input?.for(actx)
545
+ if (shell?.setDraft) {
546
+ const currentDraft = shell.state?.getSnapshot?.()?.draft ?? ''
547
+ const rawCaret = probeComposerCaret(currentDraft, targetPoint)
548
+ const { draft: next, caretAfter } = spliceInsert(currentDraft, `${mention} `, rawCaret)
549
+ shell.setDraft(next)
550
+ placeComposerCaret(next, caretAfter)
551
+ return true
552
+ }
553
+ }
554
+
555
+ return false
556
+ } catch (err) {
557
+ console.warn('[dsh-explorer] insertMentionToComposer failed:', err)
558
+ return false
559
+ }
560
+ }
561
+
562
+ export function insertSnippetToComposer(
563
+ ctx: any,
564
+ sessionId: string,
565
+ relativePath: string,
566
+ content: string,
567
+ targetPoint?: { x: number; y: number },
568
+ ): boolean {
569
+ try {
570
+ const ext = relativePath.slice(relativePath.lastIndexOf('.') + 1) || 'text'
571
+ const block = `\n\`\`\`${ext} title="${relativePath}"\n${content}\n\`\`\`\n`
572
+
573
+ const targetSessionId = resolveActiveSessionId(ctx, sessionId)
574
+ const sessions = safeService(ctx, 'sessions')
575
+ const actx = targetSessionId && sessions?.scope ? sessions.scope(targetSessionId) : undefined
576
+ const conversation = safeService(ctx, 'conversation')
577
+
578
+ if (conversation && actx) {
579
+ const shell = conversation.input?.for(actx)
580
+ if (shell) {
581
+ const span = shell.actions?.captureInsertion?.() ?? shell.captureInsertion?.()
582
+ if (span && span.draftRev !== undefined) {
583
+ if (typeof shell.actions?.insertText === 'function') {
584
+ try {
585
+ if (shell.actions.insertText(block, span)) {
586
+ shell.focus?.()
587
+ return true
588
+ }
589
+ } catch {}
590
+ }
591
+ if (typeof shell.insertText === 'function') {
592
+ try {
593
+ if (shell.insertText(block, span)) {
594
+ shell.focus?.()
595
+ return true
596
+ }
597
+ } catch {}
598
+ }
599
+ }
600
+ }
601
+ }
602
+
603
+ const el = findComposerElement()
604
+ if (el) {
605
+ if (el.isContentEditable) {
606
+ el.focus()
607
+ try {
608
+ if (document.execCommand('insertText', false, block)) {
609
+ recordLiveCaret()
610
+ return true
611
+ }
612
+ } catch {}
613
+ }
614
+
615
+ if (el.tagName === 'TEXTAREA' || el.tagName === 'INPUT') {
616
+ const textarea = el as HTMLTextAreaElement
617
+ const current = textarea.value
618
+ const rawCaret = probeComposerCaret(current, targetPoint) ?? {
619
+ start: textarea.selectionStart ?? current.length,
620
+ end: textarea.selectionEnd ?? current.length,
621
+ }
622
+ const { draft: next, caretAfter } = spliceInsert(current, block, rawCaret)
623
+ textarea.value = next
624
+ textarea.dispatchEvent(new Event('input', { bubbles: true }))
625
+ placeComposerCaret(next, caretAfter)
626
+ return true
627
+ }
628
+ }
629
+
630
+ return false
631
+ } catch (err) {
632
+ console.warn('[dsh-explorer] insertSnippetToComposer failed:', err)
633
+ return false
634
+ }
635
+ }
@@ -0,0 +1,5 @@
1
+ declare module '*.module.css' {
2
+ const classes: Record<string, string>
3
+ export default classes
4
+ }
5
+