@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,470 @@
1
+ 'use client'
2
+
3
+ import { useCallback, useRef } from 'react'
4
+ import type { Segment, ChipSegment, TriggerConfig } from './types.ts'
5
+ import { resolveTriggersInSegments } from './prompt-area-engine.ts'
6
+ import { normalizeEditorDOM, safeJsonStringify, getSelectionRange } from './dom-helpers.ts'
7
+ import {
8
+ serializeFragmentToPlainText,
9
+ serializeFragmentToSegments,
10
+ parseSegmentsFromClipboard,
11
+ insertSegmentsAtCursor,
12
+ } from './clipboard-helpers.ts'
13
+ import { htmlToMarkdown } from './html-to-markdown.ts'
14
+ import {
15
+ normalizeListPrefixText,
16
+ renumberOrderedListLines,
17
+ hasOrderedListRun,
18
+ } from './prompt-area-list-ops.ts'
19
+
20
+ // ---------------------------------------------------------------------------
21
+ // Types
22
+ // ---------------------------------------------------------------------------
23
+
24
+ type EventHandlerDeps = {
25
+ editorRef: React.RefObject<HTMLDivElement | null>
26
+ readSegmentsFromDOM: () => Segment[]
27
+ onChange: (segments: Segment[]) => void
28
+ renderSegmentsToDOM: (segments: Segment[]) => void
29
+ runTriggerDetection: () => void
30
+ dismissTrigger: () => void
31
+ triggers: TriggerConfig[]
32
+ /** When true, rich `text/html` on the clipboard is converted to markdown. */
33
+ markdownEnabled: boolean
34
+ /** When true, pasted list markers ("- ") are normalized to the "•" glyph. */
35
+ normalizeBullets: boolean
36
+ onPaste?: (data: { segments: Segment[]; source: 'internal' | 'external' }) => void
37
+ onUndo?: (segments: Segment[]) => void
38
+ onRedo?: (segments: Segment[]) => void
39
+ onChipAdd?: (chip: ChipSegment) => void
40
+ onImagePaste?: (file: File) => void
41
+ onRawPaste?: (e: React.ClipboardEvent<HTMLDivElement>) => void
42
+ }
43
+
44
+ type PromptAreaEventHandlers = {
45
+ handlePaste: (e: React.ClipboardEvent<HTMLDivElement>) => void
46
+ handleCopy: (e: React.ClipboardEvent<HTMLDivElement>) => void
47
+ handleCut: (e: React.ClipboardEvent<HTMLDivElement>) => void
48
+ handleDrop: (e: React.DragEvent<HTMLDivElement>) => void
49
+ handleDragOver: (e: React.DragEvent<HTMLDivElement>) => void
50
+ handleCompositionStart: () => void
51
+ handleCompositionEnd: () => void
52
+ handleBlur: () => void
53
+ handleKeyDownForUndoRedo: (e: React.KeyboardEvent<HTMLDivElement>) => boolean
54
+ pushUndo: (segments: Segment[]) => void
55
+ resetUndoHistory: () => void
56
+ isComposing: React.RefObject<boolean>
57
+ }
58
+
59
+ // ---------------------------------------------------------------------------
60
+ // Undo/Redo Stack
61
+ // ---------------------------------------------------------------------------
62
+
63
+ const MAX_UNDO_HISTORY = 100
64
+
65
+ /** Delay before dismissing trigger on blur, so popover clicks register first */
66
+ export const BLUR_DELAY_MS = 150
67
+
68
+ type UndoState = {
69
+ undoStack: Segment[][]
70
+ redoStack: Segment[][]
71
+ }
72
+
73
+ // ---------------------------------------------------------------------------
74
+ // Hook
75
+ // ---------------------------------------------------------------------------
76
+
77
+ /**
78
+ * Encapsulates all edge-case event handlers for the prompt area component:
79
+ * paste, copy, cut, drag/drop, IME composition, blur, and undo/redo.
80
+ */
81
+ export function usePromptAreaEvents(deps: EventHandlerDeps): PromptAreaEventHandlers {
82
+ const {
83
+ editorRef,
84
+ readSegmentsFromDOM,
85
+ onChange,
86
+ renderSegmentsToDOM,
87
+ runTriggerDetection,
88
+ dismissTrigger,
89
+ triggers,
90
+ markdownEnabled,
91
+ normalizeBullets,
92
+ onPaste: onPasteCallback,
93
+ onUndo,
94
+ onRedo,
95
+ onChipAdd,
96
+ onImagePaste,
97
+ onRawPaste,
98
+ } = deps
99
+
100
+ const isComposing = useRef(false)
101
+
102
+ // -----------------------------------------------------------------------
103
+ // Undo/redo stack (MAX_UNDO_HISTORY entries; clears redo on new push).
104
+ // Invariants: stacks live in refs (not useState) so pushUndo /
105
+ // resetUndoHistory / handleKeyDownForUndoRedo keep stable identity.
106
+ // Destabilizing this would re-create handleInput / handleKeyDown /
107
+ // imperative handle on every render and silently regress IME + debounced
108
+ // undo in the parent hook.
109
+ // -----------------------------------------------------------------------
110
+ const undoState = useRef<UndoState>({ undoStack: [], redoStack: [] })
111
+
112
+ const pushUndo = useCallback((segments: Segment[]) => {
113
+ const state = undoState.current
114
+ state.undoStack.push(segments)
115
+ if (state.undoStack.length > MAX_UNDO_HISTORY) {
116
+ state.undoStack.shift()
117
+ }
118
+ // Clear redo stack on new change
119
+ state.redoStack = []
120
+ }, [])
121
+
122
+ const resetUndoHistory = useCallback(() => {
123
+ undoState.current = { undoStack: [], redoStack: [] }
124
+ }, [])
125
+
126
+ // -----------------------------------------------------------------------
127
+ // Paste: strip HTML, insert plain text only
128
+ // -----------------------------------------------------------------------
129
+
130
+ const handlePaste = useCallback(
131
+ (e: React.ClipboardEvent<HTMLDivElement>) => {
132
+ // Let a consumer take over the paste entirely (e.g. divert large text or
133
+ // arbitrary files to an upload pipeline) by calling preventDefault().
134
+ onRawPaste?.(e)
135
+ if (e.defaultPrevented) return
136
+ e.preventDefault()
137
+
138
+ const editor = editorRef.current
139
+ if (!editor) return
140
+
141
+ // Check for image files in clipboard before processing text
142
+ // Some browsers/OSes provide pasted images via `items` instead of `files` (e.g. screenshots)
143
+ const imageFile =
144
+ Array.from(e.clipboardData.files).find((f) => f.type.startsWith('image/')) ??
145
+ (() => {
146
+ const item = Array.from(e.clipboardData.items).find((i) => i.type.startsWith('image/'))
147
+ return item?.getAsFile() ?? null
148
+ })()
149
+ if (imageFile) {
150
+ onImagePaste?.(imageFile)
151
+ return
152
+ }
153
+
154
+ // Record undo snapshot
155
+ const currentSegments = readSegmentsFromDOM()
156
+ pushUndo(currentSegments)
157
+
158
+ // Check for internal segment data (copy/paste within the editor)
159
+ const segmentJson = e.clipboardData.getData('text/prompt-area-segments')
160
+ if (segmentJson) {
161
+ const parsed = parseSegmentsFromClipboard(segmentJson)
162
+ if (parsed && parsed.length > 0) {
163
+ // Insert the copied segments at cursor position
164
+ const range = getSelectionRange()
165
+ if (!range) return
166
+
167
+ range.deleteContents()
168
+
169
+ // Merge pasted segments into current segments at cursor position
170
+ const beforePaste = readSegmentsFromDOM()
171
+ const merged = insertSegmentsAtCursor(beforePaste, parsed, editor)
172
+ onChange(merged)
173
+ renderSegmentsToDOM(merged)
174
+
175
+ // Notify: internal paste with chip data preserved
176
+ onPasteCallback?.({ segments: merged, source: 'internal' })
177
+ for (const seg of parsed) {
178
+ if (seg.type === 'chip') {
179
+ onChipAdd?.(seg)
180
+ }
181
+ }
182
+
183
+ runTriggerDetection()
184
+ return
185
+ }
186
+ }
187
+
188
+ // When markdown mode is on, prefer the richest clipboard flavor:
189
+ // 1. text/markdown — some apps (e.g. Slack) hand out markdown directly,
190
+ // preserving nested lists that their text/plain flattens.
191
+ // 2. text/html — convert web/Notion/Docs/GitHub HTML to markdown.
192
+ // Otherwise (markdown off, or neither present) fall back to plain text.
193
+ let text = ''
194
+ if (markdownEnabled) {
195
+ text = e.clipboardData.getData('text/markdown')
196
+ if (text) {
197
+ // Slack over-escapes inert punctuation (e.g. `\(` `\)`); unescape
198
+ // parentheses so the source reads cleanly. They carry no markdown
199
+ // meaning, unlike `\*` / `\.` / `\-` which are left intact.
200
+ text = text.replace(/\\([()])/g, '$1')
201
+ } else {
202
+ const html = e.clipboardData.getData('text/html')
203
+ // A converter failure (e.g. stack overflow on pathologically deep
204
+ // nesting) must not drop the paste — leave text empty so the
205
+ // text/plain fallback below still runs.
206
+ if (html) {
207
+ try {
208
+ text = htmlToMarkdown(html)
209
+ } catch {
210
+ text = ''
211
+ }
212
+ }
213
+ }
214
+ }
215
+ if (!text) text = e.clipboardData.getData('text/plain')
216
+ if (!text) return
217
+
218
+ // Normalize pasted list markers ("- " → "•") so pasted bullets match
219
+ // typed input. Applies to both the HTML→markdown and plain-text paths.
220
+ if (markdownEnabled && normalizeBullets) {
221
+ text = normalizeListPrefixText(text, true)
222
+ }
223
+
224
+ // Rebuild ordered-list numbering in the pasted block so a copied list with
225
+ // stale numbers lands sequential. Gated on `hasOrderedListRun` so incidental
226
+ // numeric-leading prose (e.g. `1985. Born / 2020. Died`) is left untouched.
227
+ // Done at the raw-string level (the caret collapses to end-of-content after
228
+ // insertion, so no offset remap needed).
229
+ if (markdownEnabled && hasOrderedListRun(text)) {
230
+ text = renumberOrderedListLines(text).text
231
+ }
232
+
233
+ // Insert plain text at cursor position using Selection API
234
+ const range = getSelectionRange()
235
+ if (!range) return
236
+
237
+ range.deleteContents()
238
+
239
+ // Handle multi-line paste: split into lines with BR elements
240
+ const lines = text.split('\n')
241
+ const fragment = document.createDocumentFragment()
242
+
243
+ for (let i = 0; i < lines.length; i++) {
244
+ if (lines[i]) {
245
+ fragment.appendChild(document.createTextNode(lines[i]))
246
+ }
247
+ if (i < lines.length - 1) {
248
+ fragment.appendChild(document.createElement('br'))
249
+ }
250
+ }
251
+
252
+ range.insertNode(fragment)
253
+
254
+ // Move cursor to end of pasted content
255
+ range.collapse(false)
256
+ const sel = window.getSelection()
257
+ sel?.removeAllRanges()
258
+ sel?.addRange(range)
259
+
260
+ // Normalize DOM, sync model, detect triggers
261
+ normalizeEditorDOM(editor)
262
+ const newSegments = readSegmentsFromDOM()
263
+
264
+ // Auto-resolve trigger patterns in pasted text (e.g., #readme -> chip)
265
+ const resolvedSegments = resolveTriggersInSegments(newSegments, triggers)
266
+
267
+ if (resolvedSegments !== newSegments) {
268
+ onChange(resolvedSegments)
269
+ renderSegmentsToDOM(resolvedSegments)
270
+
271
+ // Notify about auto-resolved chips from pasted text
272
+ for (const seg of resolvedSegments) {
273
+ if (
274
+ seg.type === 'chip' &&
275
+ !newSegments.some(
276
+ (s) =>
277
+ s.type === 'chip' &&
278
+ s.trigger === seg.trigger &&
279
+ s.value === seg.value &&
280
+ s.displayText === seg.displayText,
281
+ )
282
+ ) {
283
+ onChipAdd?.(seg)
284
+ }
285
+ }
286
+ } else {
287
+ onChange(newSegments)
288
+ }
289
+
290
+ onPasteCallback?.({ segments: resolvedSegments, source: 'external' })
291
+ runTriggerDetection()
292
+ },
293
+ [
294
+ editorRef,
295
+ readSegmentsFromDOM,
296
+ onChange,
297
+ pushUndo,
298
+ runTriggerDetection,
299
+ renderSegmentsToDOM,
300
+ triggers,
301
+ markdownEnabled,
302
+ normalizeBullets,
303
+ onPasteCallback,
304
+ onChipAdd,
305
+ onImagePaste,
306
+ onRawPaste,
307
+ ],
308
+ )
309
+
310
+ // -----------------------------------------------------------------------
311
+ // Copy: serialize chips into plain text
312
+ // -----------------------------------------------------------------------
313
+
314
+ const handleCopy = useCallback((e: React.ClipboardEvent<HTMLDivElement>) => {
315
+ e.preventDefault()
316
+
317
+ const range = getSelectionRange()
318
+ if (!range) return
319
+
320
+ const fragment = range.cloneContents()
321
+
322
+ // Walk fragment and serialize, converting chips to their text representation
323
+ const plainText = serializeFragmentToPlainText(fragment)
324
+ e.clipboardData.setData('text/plain', plainText)
325
+
326
+ // Also serialize chip segments as JSON for internal paste
327
+ const fragmentSegments = serializeFragmentToSegments(fragment)
328
+ const hasChips = fragmentSegments.some((s) => s.type === 'chip')
329
+ if (hasChips) {
330
+ const json = safeJsonStringify(fragmentSegments)
331
+ if (json) {
332
+ e.clipboardData.setData('text/prompt-area-segments', json)
333
+ }
334
+ }
335
+ }, [])
336
+
337
+ // -----------------------------------------------------------------------
338
+ // Cut: copy + delete
339
+ // -----------------------------------------------------------------------
340
+
341
+ const handleCut = useCallback(
342
+ (e: React.ClipboardEvent<HTMLDivElement>) => {
343
+ // First, do the copy
344
+ handleCopy(e)
345
+
346
+ // Then delete the selection
347
+ const range = getSelectionRange()
348
+ if (!range) return
349
+
350
+ const currentSegments = readSegmentsFromDOM()
351
+ pushUndo(currentSegments)
352
+
353
+ range.deleteContents()
354
+
355
+ const editor = editorRef.current
356
+ if (editor) {
357
+ normalizeEditorDOM(editor)
358
+ }
359
+
360
+ const newSegments = readSegmentsFromDOM()
361
+ onChange(newSegments)
362
+ runTriggerDetection()
363
+ },
364
+ [handleCopy, editorRef, readSegmentsFromDOM, onChange, pushUndo, runTriggerDetection],
365
+ )
366
+
367
+ // -----------------------------------------------------------------------
368
+ // Drag & Drop: prevent to avoid unpredictable DOM mutations
369
+ // -----------------------------------------------------------------------
370
+
371
+ const handleDrop = useCallback((e: React.DragEvent<HTMLDivElement>) => {
372
+ e.preventDefault()
373
+ }, [])
374
+
375
+ const handleDragOver = useCallback((e: React.DragEvent<HTMLDivElement>) => {
376
+ e.preventDefault()
377
+ }, [])
378
+
379
+ // -----------------------------------------------------------------------
380
+ // IME Composition: track state, defer trigger detection
381
+ // -----------------------------------------------------------------------
382
+
383
+ const handleCompositionStart = useCallback(() => {
384
+ isComposing.current = true
385
+ }, [])
386
+
387
+ const handleCompositionEnd = useCallback(() => {
388
+ isComposing.current = false
389
+ // Run trigger detection after composition ends
390
+ runTriggerDetection()
391
+ }, [runTriggerDetection])
392
+
393
+ // -----------------------------------------------------------------------
394
+ // Blur: dismiss trigger dropdown with delay (so popover clicks work)
395
+ // -----------------------------------------------------------------------
396
+
397
+ const handleBlur = useCallback(() => {
398
+ setTimeout(() => {
399
+ const editor = editorRef.current
400
+ if (!editor) return
401
+
402
+ // Only dismiss if focus didn't move to an element within the editor container
403
+ const activeEl = document.activeElement
404
+ if (activeEl && editor.parentElement?.contains(activeEl)) return
405
+
406
+ dismissTrigger()
407
+ }, BLUR_DELAY_MS)
408
+ }, [editorRef, dismissTrigger])
409
+
410
+ // -----------------------------------------------------------------------
411
+ // Undo/Redo: intercept Ctrl+Z / Ctrl+Shift+Z
412
+ // -----------------------------------------------------------------------
413
+
414
+ const handleKeyDownForUndoRedo = useCallback(
415
+ (e: React.KeyboardEvent<HTMLDivElement>): boolean => {
416
+ const isMeta = e.metaKey || e.ctrlKey
417
+
418
+ if (!isMeta || e.key !== 'z') return false
419
+
420
+ e.preventDefault()
421
+ const state = undoState.current
422
+
423
+ if (e.shiftKey) {
424
+ // Redo: Ctrl+Shift+Z
425
+ if (state.redoStack.length === 0) return true
426
+
427
+ const segments = state.redoStack.pop()
428
+ if (!segments) return true
429
+
430
+ const current = readSegmentsFromDOM()
431
+ state.undoStack.push(current)
432
+
433
+ onChange(segments)
434
+ renderSegmentsToDOM(segments)
435
+ onRedo?.(segments)
436
+ } else {
437
+ // Undo: Ctrl+Z
438
+ if (state.undoStack.length === 0) return true
439
+
440
+ const segments = state.undoStack.pop()
441
+ if (!segments) return true
442
+
443
+ const current = readSegmentsFromDOM()
444
+ state.redoStack.push(current)
445
+
446
+ onChange(segments)
447
+ renderSegmentsToDOM(segments)
448
+ onUndo?.(segments)
449
+ }
450
+
451
+ return true
452
+ },
453
+ [readSegmentsFromDOM, onChange, renderSegmentsToDOM, onUndo, onRedo],
454
+ )
455
+
456
+ return {
457
+ handlePaste,
458
+ handleCopy,
459
+ handleCut,
460
+ handleDrop,
461
+ handleDragOver,
462
+ handleCompositionStart,
463
+ handleCompositionEnd,
464
+ handleBlur,
465
+ handleKeyDownForUndoRedo,
466
+ pushUndo,
467
+ resetUndoHistory,
468
+ isComposing,
469
+ }
470
+ }
@@ -0,0 +1,131 @@
1
+ /**
2
+ * Convenience hook that wires up all the boilerplate state for a PromptArea.
3
+ *
4
+ * Instead of manually managing `useState<Segment[]>`, `useRef<PromptAreaHandle>`,
5
+ * and computing derived values, call `usePromptAreaState()` once and spread
6
+ * `bind` into your `<PromptArea>`.
7
+ *
8
+ * @example
9
+ * ```tsx
10
+ * function ChatInput() {
11
+ * const { bind, plainText, isEmpty, chips, clear, focus } = usePromptAreaState()
12
+ *
13
+ * return (
14
+ * <PromptArea
15
+ * {...bind}
16
+ * onSubmit={() => {
17
+ * sendMessage(plainText)
18
+ * clear()
19
+ * }}
20
+ * />
21
+ * )
22
+ * }
23
+ * ```
24
+ */
25
+
26
+ 'use client'
27
+
28
+ import { useCallback, useMemo, useRef, useState } from 'react'
29
+ import type { Segment, ChipSegment, PromptAreaHandle } from './types.ts'
30
+ import { segmentsToPlainText } from './prompt-area-engine.ts'
31
+
32
+ // ---------------------------------------------------------------------------
33
+ // Options
34
+ // ---------------------------------------------------------------------------
35
+
36
+ export type UsePromptAreaStateOptions = {
37
+ /** Initial segment value. Defaults to `[]`. */
38
+ initialValue?: Segment[]
39
+ }
40
+
41
+ // ---------------------------------------------------------------------------
42
+ // Return type
43
+ // ---------------------------------------------------------------------------
44
+
45
+ export type PromptAreaBind = {
46
+ /** Ref to attach to PromptArea — gives access to imperative methods. */
47
+ ref: React.RefObject<PromptAreaHandle | null>
48
+ /** Current segment array — pass as `value` prop. */
49
+ value: Segment[]
50
+ /** Setter — pass as `onChange` prop. */
51
+ onChange: (segments: Segment[]) => void
52
+ }
53
+
54
+ export type PromptAreaState = {
55
+ /** Props to spread directly onto `<PromptArea {...bind} />`. Contains ref, value, and onChange. */
56
+ bind: PromptAreaBind
57
+ /** Derived plain text representation of the current value. */
58
+ plainText: string
59
+ /** `true` when the value is empty or whitespace-only. */
60
+ isEmpty: boolean
61
+ /** `true` when the value contains at least one chip. */
62
+ hasChips: boolean
63
+ /** All chip segments in the current value. */
64
+ chips: ChipSegment[]
65
+ /** Clear all content (both state and the editor DOM). */
66
+ clear: () => void
67
+ /** Focus the editor. */
68
+ focus: () => void
69
+ /** Blur the editor. */
70
+ blur: () => void
71
+ /** Insert a chip at the current cursor position. */
72
+ insertChip: (chip: Omit<ChipSegment, 'type'>) => void
73
+ }
74
+
75
+ // ---------------------------------------------------------------------------
76
+ // Hook
77
+ // ---------------------------------------------------------------------------
78
+
79
+ export function usePromptAreaState(options: UsePromptAreaStateOptions = {}): PromptAreaState {
80
+ const { initialValue = [] } = options
81
+
82
+ const [value, setValue] = useState<Segment[]>(initialValue)
83
+ const ref = useRef<PromptAreaHandle>(null)
84
+
85
+ // Derived
86
+ const plainText = useMemo(() => segmentsToPlainText(value), [value])
87
+
88
+ const isEmpty = useMemo(() => {
89
+ if (value.length === 0) return true
90
+ return value.every((seg) => seg.type === 'text' && seg.text.trim() === '')
91
+ }, [value])
92
+
93
+ const hasChips = useMemo(() => value.some((seg) => seg.type === 'chip'), [value])
94
+
95
+ const chips = useMemo(
96
+ () => value.filter((seg): seg is ChipSegment => seg.type === 'chip'),
97
+ [value],
98
+ )
99
+
100
+ // Bind object — safe to spread onto <PromptArea>
101
+ const bind = useMemo<PromptAreaBind>(() => ({ ref, value, onChange: setValue }), [value])
102
+
103
+ // Actions that proxy to the imperative handle
104
+ const clear = useCallback(() => {
105
+ if (ref.current) {
106
+ ref.current.clear()
107
+ } else {
108
+ setValue([])
109
+ }
110
+ }, [])
111
+
112
+ const focus = useCallback(() => ref.current?.focus(), [])
113
+ const blur = useCallback(() => ref.current?.blur(), [])
114
+
115
+ const insertChip = useCallback(
116
+ (chip: Omit<ChipSegment, 'type'>) => ref.current?.insertChip(chip),
117
+ [],
118
+ )
119
+
120
+ return {
121
+ bind,
122
+ plainText,
123
+ isEmpty,
124
+ hasChips,
125
+ chips,
126
+ clear,
127
+ focus,
128
+ blur,
129
+ insertChip,
130
+ }
131
+ }