@workerdeck/ui 0.22.0 → 1.0.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-DgwjH4Ve.mjs → SessionPanel-DDgBkHsi.mjs} +2196 -3364
  2. package/build/SessionPanel-DDgBkHsi.mjs.map +1 -0
  3. package/build/SessionPanel-E_L_ldQ5.d.mts +207 -0
  4. package/build/format-B8ZM9LPy.d.mts +16 -0
  5. package/build/format.d.mts +8 -75
  6. package/build/format.mjs +2 -2
  7. package/build/index.d.mts +63 -1138
  8. package/build/index.mjs +4 -300
  9. package/build/index.mjs.map +1 -1
  10. package/build/scoped.css +80 -24
  11. package/build/{status-BE-zg88x.mjs → status-C0HSLZas.mjs} +14 -85
  12. package/build/status-C0HSLZas.mjs.map +1 -0
  13. package/build/workspace.d.mts +9 -124
  14. package/build/workspace.mjs +13 -143
  15. package/build/workspace.mjs.map +1 -1
  16. package/package.json +4 -4
  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 +4 -6
  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 +345 -923
  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 +108 -215
  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 +136 -19
  141. package/src/workspace.ts +1 -24
  142. package/build/SessionPanel-13l25ubU.d.mts +0 -609
  143. package/build/SessionPanel-DgwjH4Ve.mjs.map +0 -1
  144. package/build/format-ljc3lKpA.d.mts +0 -59
  145. package/build/status-BE-zg88x.mjs.map +0 -1
@@ -0,0 +1,598 @@
1
+ 'use client'
2
+
3
+ import { useCallback } from 'react'
4
+ import type { Segment, TriggerConfig, ActiveTrigger, TriggerSuggestion, ChipSegment } from './types.ts'
5
+ import {
6
+ isValidTriggerPosition,
7
+ segmentsToPlainText,
8
+ resolveChip,
9
+ removeChipAtIndex,
10
+ revertChipAtIndex,
11
+ replaceTextRange,
12
+ toggleMarkdownWrap,
13
+ } from './prompt-area-engine.ts'
14
+ import {
15
+ getListContext,
16
+ insertListContinuation,
17
+ indentListItem,
18
+ outdentListItem,
19
+ removeListPrefix,
20
+ renumberOrderedListSegments,
21
+ remapOffset,
22
+ } from './prompt-area-list-ops.ts'
23
+ import {
24
+ isChipElement,
25
+ getChipAutoResolved,
26
+ getDirectChildContaining,
27
+ indexOfChildNode,
28
+ domChildIndexToSegmentIndex,
29
+ getSelectionRange,
30
+ } from './dom-helpers.ts'
31
+ import { getCursorOffset, setCursorAtOffset, getSelectionOffsets, setSelectionAtOffsets } from './cursor-helpers.ts'
32
+
33
+ type UsePromptAreaKeydownOptions = {
34
+ editorRef: React.RefObject<HTMLDivElement | null>
35
+ triggers: TriggerConfig[]
36
+ markdownEnabled: boolean
37
+ submitOnEnter: boolean
38
+ onSubmit?: (segments: Segment[]) => void
39
+ onEscape?: () => void
40
+ onChange: (segments: Segment[]) => void
41
+ onChipAdd?: (chip: ChipSegment) => void
42
+ onChipDelete?: (chip: ChipSegment) => void
43
+ readSegmentsFromDOM: () => Segment[]
44
+ renderSegmentsToDOM: (segments: Segment[]) => void
45
+ runTriggerDetection: () => void
46
+ dismissTrigger: () => void
47
+ buildInsertChip: (segments: Segment[], trigger: ActiveTrigger) => (chip: Omit<ChipSegment, 'type'>) => void
48
+ selectSuggestionInternal: (suggestion: TriggerSuggestion) => void
49
+ activeTrigger: ActiveTrigger | null
50
+ suggestions: TriggerSuggestion[]
51
+ suggestionsLoading: boolean
52
+ suggestionsError: string | null
53
+ selectedSuggestionIndex: number
54
+ setSelectedSuggestionIndex: React.Dispatch<React.SetStateAction<number>>
55
+ pushUndo: (segments: Segment[]) => void
56
+ handleKeyDownForUndoRedo: (e: React.KeyboardEvent<HTMLDivElement>) => boolean
57
+ lastRenderedValue: React.RefObject<Segment[]>
58
+ undoTimer: React.RefObject<ReturnType<typeof setTimeout> | null>
59
+ undoBaseState: React.RefObject<Segment[] | null>
60
+ }
61
+
62
+ // The numbered keydown router (branches 1 through 6) and the chip-deletion helpers only it
63
+ // calls, moved whole from use-prompt-area.ts. The numbering is load-bearing documentation:
64
+ // branch order IS the precedence order.
65
+ export function usePromptAreaKeydown({
66
+ editorRef,
67
+ triggers,
68
+ markdownEnabled,
69
+ submitOnEnter,
70
+ onSubmit,
71
+ onEscape,
72
+ onChange,
73
+ onChipAdd,
74
+ onChipDelete,
75
+ readSegmentsFromDOM,
76
+ renderSegmentsToDOM,
77
+ runTriggerDetection,
78
+ dismissTrigger,
79
+ buildInsertChip,
80
+ selectSuggestionInternal,
81
+ activeTrigger,
82
+ suggestions,
83
+ suggestionsLoading,
84
+ suggestionsError,
85
+ selectedSuggestionIndex,
86
+ setSelectedSuggestionIndex,
87
+ pushUndo,
88
+ handleKeyDownForUndoRedo,
89
+ lastRenderedValue,
90
+ undoTimer,
91
+ undoBaseState,
92
+ }: UsePromptAreaKeydownOptions): (e: React.KeyboardEvent<HTMLDivElement>) => void {
93
+ // Remove a chip node from DOM and sync model
94
+
95
+ const removeChipNodeFromDOM = useCallback(
96
+ (editor: HTMLElement, chipNode: HTMLElement): boolean => {
97
+ const segments = readSegmentsFromDOM()
98
+ const chipIdx = indexOfChildNode(editor, chipNode)
99
+ if (chipIdx === -1) {
100
+ return false
101
+ }
102
+
103
+ const segIdx = domChildIndexToSegmentIndex(editor, chipIdx)
104
+ const deletedChip = segments[segIdx]
105
+ const newSegments = removeChipAtIndex(segments, segIdx)
106
+ onChange(newSegments)
107
+ renderSegmentsToDOM(newSegments)
108
+
109
+ if (deletedChip?.type === 'chip') {
110
+ onChipDelete?.(deletedChip)
111
+ }
112
+
113
+ return true
114
+ },
115
+ [readSegmentsFromDOM, onChange, renderSegmentsToDOM, onChipDelete],
116
+ )
117
+
118
+ // Revert an auto-resolved chip back to plain text
119
+
120
+ const revertChipNodeToText = useCallback(
121
+ (editor: HTMLElement, chipNode: HTMLElement): boolean => {
122
+ const segments = readSegmentsFromDOM()
123
+ const chipIdx = indexOfChildNode(editor, chipNode)
124
+ if (chipIdx === -1) {
125
+ return false
126
+ }
127
+
128
+ const segIdx = domChildIndexToSegmentIndex(editor, chipIdx)
129
+ const revertedChip = segments[segIdx]
130
+ const result = revertChipAtIndex(segments, segIdx)
131
+ if (!result) {
132
+ return false
133
+ }
134
+
135
+ let targetOffset = 0
136
+ for (let i = 0; i < segIdx; i++) {
137
+ const s = segments[i]
138
+ if (s.type === 'text') {
139
+ targetOffset += s.text.length
140
+ } else {
141
+ targetOffset += s.trigger.length + s.displayText.length
142
+ }
143
+ }
144
+ targetOffset += result.revertedText.length
145
+
146
+ onChange(result.segments)
147
+ renderSegmentsToDOM(result.segments)
148
+ setCursorAtOffset(editor, targetOffset)
149
+
150
+ if (revertedChip?.type === 'chip') {
151
+ onChipDelete?.(revertedChip)
152
+ }
153
+
154
+ return true
155
+ },
156
+ [readSegmentsFromDOM, onChange, renderSegmentsToDOM, onChipDelete],
157
+ )
158
+
159
+ // Chip backspace (delete chip behind cursor as whole unit)
160
+
161
+ const handleChipBackspace = useCallback((): boolean => {
162
+ const editor = editorRef.current
163
+ if (!editor) {
164
+ return false
165
+ }
166
+
167
+ const range = getSelectionRange()
168
+ if (!range || !range.collapsed) {
169
+ return false
170
+ }
171
+
172
+ const node = range.startContainer
173
+ const offset = range.startOffset
174
+
175
+ // Case 1: cursor is at the editor level (between child nodes)
176
+ if (node === editor && offset > 0) {
177
+ const prevChild = editor.childNodes[offset - 1]
178
+ if (prevChild && isChipElement(prevChild)) {
179
+ if (getChipAutoResolved(prevChild)) {
180
+ return revertChipNodeToText(editor, prevChild)
181
+ }
182
+ return removeChipNodeFromDOM(editor, prevChild)
183
+ }
184
+ }
185
+
186
+ // Case 2: cursor is at start of a text node, check previous sibling
187
+ if (node.nodeType === Node.TEXT_NODE && offset === 0) {
188
+ const directChild = getDirectChildContaining(editor, node)
189
+ if (!directChild) {
190
+ return false
191
+ }
192
+
193
+ let prevSibling = directChild.previousSibling
194
+ while (prevSibling && prevSibling.nodeType === Node.TEXT_NODE && prevSibling.textContent === '') {
195
+ prevSibling = prevSibling.previousSibling
196
+ }
197
+ if (prevSibling && isChipElement(prevSibling)) {
198
+ if (getChipAutoResolved(prevSibling)) {
199
+ return revertChipNodeToText(editor, prevSibling)
200
+ }
201
+ return removeChipNodeFromDOM(editor, prevSibling)
202
+ }
203
+ }
204
+
205
+ return false
206
+ }, [removeChipNodeFromDOM, revertChipNodeToText])
207
+
208
+ // Chip forward delete (delete chip in front of cursor)
209
+
210
+ const handleChipForwardDelete = useCallback((): boolean => {
211
+ const editor = editorRef.current
212
+ if (!editor) {
213
+ return false
214
+ }
215
+
216
+ const range = getSelectionRange()
217
+ if (!range || !range.collapsed) {
218
+ return false
219
+ }
220
+
221
+ const node = range.startContainer
222
+ const offset = range.startOffset
223
+
224
+ // Case 1: cursor at the editor level
225
+ if (node === editor && offset < editor.childNodes.length) {
226
+ const nextChild = editor.childNodes[offset]
227
+ if (nextChild && isChipElement(nextChild)) {
228
+ return removeChipNodeFromDOM(editor, nextChild)
229
+ }
230
+ }
231
+
232
+ // Case 2: cursor at end of a text node, check next sibling
233
+ if (node.nodeType === Node.TEXT_NODE && offset === (node.textContent ?? '').length) {
234
+ const directChild = getDirectChildContaining(editor, node)
235
+ if (!directChild) {
236
+ return false
237
+ }
238
+
239
+ let nextSibling = directChild.nextSibling
240
+ while (nextSibling && nextSibling.nodeType === Node.TEXT_NODE && nextSibling.textContent === '') {
241
+ nextSibling = nextSibling.nextSibling
242
+ }
243
+ if (nextSibling && isChipElement(nextSibling)) {
244
+ return removeChipNodeFromDOM(editor, nextSibling)
245
+ }
246
+ }
247
+
248
+ return false
249
+ }, [removeChipNodeFromDOM])
250
+
251
+ // Auto-resolve active trigger on space
252
+
253
+ const autoResolveActiveTrigger = useCallback(
254
+ (trigger: ActiveTrigger) => {
255
+ const segments = readSegmentsFromDOM()
256
+ const query = trigger.query
257
+
258
+ const syntheticSuggestion: TriggerSuggestion = {
259
+ value: query,
260
+ label: query,
261
+ }
262
+
263
+ const displayText = trigger.config.onSelect?.(syntheticSuggestion) ?? query
264
+
265
+ const chipData = {
266
+ value: query,
267
+ displayText: displayText || query,
268
+ autoResolved: true,
269
+ }
270
+ const result = resolveChip(segments, trigger, chipData)
271
+
272
+ onChange(result.segments)
273
+ renderSegmentsToDOM(result.segments)
274
+
275
+ onChipAdd?.({
276
+ type: 'chip',
277
+ trigger: trigger.config.char,
278
+ ...chipData,
279
+ })
280
+
281
+ const editor = editorRef.current
282
+ if (editor) {
283
+ setCursorAtOffset(editor, result.cursorOffset)
284
+ }
285
+
286
+ dismissTrigger()
287
+ },
288
+ [readSegmentsFromDOM, onChange, renderSegmentsToDOM, dismissTrigger, onChipAdd],
289
+ )
290
+
291
+ // Handle key events
292
+
293
+ const handleKeyDown = useCallback(
294
+ (e: React.KeyboardEvent<HTMLDivElement>) => {
295
+ const applyEditResult = (editor: HTMLDivElement, result: { segments: Segment[]; cursorOffset: number }) => {
296
+ // Ordered-list numbers are a projection of position: rebuild them on
297
+ // every structural edit and remap the caret across any digit-run width
298
+ // changes. No-op (same reference) when there are no ordered lists.
299
+ let { segments, cursorOffset } = result
300
+ if (markdownEnabled) {
301
+ const renumbered = renumberOrderedListSegments(segments)
302
+ segments = renumbered.segments
303
+ cursorOffset = remapOffset(cursorOffset, renumbered.edits)
304
+ }
305
+ lastRenderedValue.current = segments
306
+ onChange(segments)
307
+ renderSegmentsToDOM(segments)
308
+ setCursorAtOffset(editor, cursorOffset)
309
+ }
310
+
311
+ const tryListContinuation = (editor: HTMLDivElement): boolean => {
312
+ if (!markdownEnabled) {
313
+ return false
314
+ }
315
+ const segments = readSegmentsFromDOM()
316
+ const cursorPos = getCursorOffset(editor)
317
+ if (cursorPos === null) {
318
+ return false
319
+ }
320
+ const plainText = segmentsToPlainText(segments)
321
+ if (!getListContext(plainText, cursorPos)) {
322
+ return false
323
+ }
324
+ const result = insertListContinuation(segments, cursorPos)
325
+ if (result) {
326
+ applyEditResult(editor, result)
327
+ }
328
+ return true
329
+ }
330
+
331
+ // 1. Flush pending undo debounce so Cmd+Z has the latest checkpoint
332
+ if ((e.metaKey || e.ctrlKey) && e.key === 'z' && undoBaseState.current) {
333
+ if (undoTimer.current) {
334
+ clearTimeout(undoTimer.current)
335
+ undoTimer.current = null
336
+ }
337
+ pushUndo(undoBaseState.current)
338
+ undoBaseState.current = null
339
+ }
340
+
341
+ // 1a. Undo/redo intercept
342
+ if (handleKeyDownForUndoRedo(e)) {
343
+ return
344
+ }
345
+
346
+ // 1.5 Markdown formatting shortcuts (Cmd+B bold, Cmd+I italic)
347
+ if (markdownEnabled && (e.metaKey || e.ctrlKey) && !e.shiftKey && (e.key === 'b' || e.key === 'i')) {
348
+ e.preventDefault()
349
+ const editor = editorRef.current
350
+ if (!editor) {
351
+ return
352
+ }
353
+
354
+ const offsets = getSelectionOffsets(editor)
355
+ if (!offsets || offsets.start === offsets.end) {
356
+ return
357
+ }
358
+
359
+ const marker = e.key === 'b' ? '**' : '*'
360
+ const currentSegments = readSegmentsFromDOM()
361
+ pushUndo(currentSegments)
362
+
363
+ const result = toggleMarkdownWrap(currentSegments, offsets.start, offsets.end, marker)
364
+ if (!result) {
365
+ return
366
+ }
367
+
368
+ lastRenderedValue.current = result.segments
369
+ onChange(result.segments)
370
+ renderSegmentsToDOM(result.segments)
371
+ setSelectionAtOffsets(editor, result.selectionStart, result.selectionEnd)
372
+ return
373
+ }
374
+
375
+ // 1.75 Launch triggers: a trigger with mode 'launch' fires onActivate on
376
+ // keydown and suppresses the char so it never enters the editor — for
377
+ // opening an external surface (dialog, palette). The DOM read is gated on
378
+ // the typed key actually matching a launch char, so it stays off the hot
379
+ // path. insertChip still inserts a chip at the cursor if the consumer
380
+ // wants one after the external selection.
381
+ if (!e.metaKey && !e.ctrlKey && !e.altKey && !e.nativeEvent.isComposing && e.key.length === 1) {
382
+ const launcher = triggers.find((t) => t.mode === 'launch' && t.char === e.key)
383
+ const editor = editorRef.current
384
+ if (launcher?.onActivate && editor) {
385
+ const cursorPos = getCursorOffset(editor)
386
+ if (cursorPos !== null) {
387
+ const segments = readSegmentsFromDOM()
388
+ const plainText = segmentsToPlainText(segments)
389
+ if (isValidTriggerPosition(plainText, cursorPos, launcher.position)) {
390
+ e.preventDefault()
391
+ launcher.onActivate({
392
+ text: plainText,
393
+ cursorPosition: cursorPos,
394
+ insertChip: buildInsertChip(replaceTextRange(segments, cursorPos, cursorPos, launcher.char), {
395
+ config: launcher,
396
+ startOffset: cursorPos,
397
+ query: '',
398
+ }),
399
+ })
400
+ return
401
+ }
402
+ }
403
+ }
404
+ }
405
+
406
+ // 2. Trigger dropdown navigation. Gated on the dropdown actually being
407
+ // ON SCREEN, which matches TriggerPopover's own render condition
408
+ // (non-empty suggestions, OR loading/error/emptyMessage) rather than
409
+ // just `suggestions.length > 0` — otherwise a popover left open in a
410
+ // loading/empty state (e.g. right after a chip-click reopen, before its
411
+ // empty-query search resolves) lets Enter fall through to onSubmit and
412
+ // Escape fall through to onEscape while still visibly on screen.
413
+ const dropdownVisible =
414
+ activeTrigger &&
415
+ activeTrigger.config.mode === 'dropdown' &&
416
+ (suggestions.length > 0 || suggestionsLoading || suggestionsError !== null || !!activeTrigger.config.emptyMessage)
417
+ if (dropdownVisible) {
418
+ if (e.key === 'ArrowDown') {
419
+ e.preventDefault()
420
+ if (suggestions.length > 0) {
421
+ setSelectedSuggestionIndex((prev) => Math.min(prev + 1, suggestions.length - 1))
422
+ }
423
+ return
424
+ }
425
+ if (e.key === 'ArrowUp') {
426
+ e.preventDefault()
427
+ if (suggestions.length > 0) {
428
+ setSelectedSuggestionIndex((prev) => Math.max(prev - 1, 0))
429
+ }
430
+ return
431
+ }
432
+ if (e.key === 'Enter' || e.key === 'Tab') {
433
+ e.preventDefault()
434
+ const selected = suggestions[selectedSuggestionIndex]
435
+ if (selected) {
436
+ selectSuggestionInternal(selected)
437
+ }
438
+ return
439
+ }
440
+ if (e.key === 'Escape') {
441
+ e.preventDefault()
442
+ dismissTrigger()
443
+ return
444
+ }
445
+ }
446
+
447
+ // 2.5. Auto-resolve on Space when trigger has resolveOnSpace
448
+ if (e.key === ' ' && activeTrigger && activeTrigger.config.resolveOnSpace) {
449
+ const query = activeTrigger.query.trim()
450
+ if (query.length > 0) {
451
+ e.preventDefault()
452
+ autoResolveActiveTrigger(activeTrigger)
453
+ return
454
+ }
455
+ }
456
+
457
+ // 2.6. Tab/Shift+Tab for list indentation (only when trigger dropdown is NOT open)
458
+ if (markdownEnabled && e.key === 'Tab' && !activeTrigger) {
459
+ const editor = editorRef.current
460
+ if (editor) {
461
+ const segments = readSegmentsFromDOM()
462
+ const plainText = segmentsToPlainText(segments)
463
+ const cursorPos = getCursorOffset(editor)
464
+ if (cursorPos !== null) {
465
+ const ctx = getListContext(plainText, cursorPos)
466
+ if (ctx) {
467
+ e.preventDefault()
468
+ const result = e.shiftKey ? outdentListItem(segments, cursorPos) : indentListItem(segments, cursorPos)
469
+ if (result) {
470
+ applyEditResult(editor, result)
471
+ }
472
+ return
473
+ }
474
+ }
475
+ }
476
+ }
477
+
478
+ // Insert a newline at the model level (avoids the browser's broken
479
+ // contentEditable behaviour near <a> elements).
480
+ const insertPlainNewline = (editor: HTMLDivElement): void => {
481
+ const offsets = getSelectionOffsets(editor)
482
+ if (!offsets) {
483
+ return
484
+ }
485
+ const currentSegments = readSegmentsFromDOM()
486
+ pushUndo(currentSegments)
487
+ const newSegments = replaceTextRange(currentSegments, offsets.start, offsets.end, '\n')
488
+ applyEditResult(editor, { segments: newSegments, cursorOffset: offsets.start + 1 })
489
+ }
490
+
491
+ // 2.8 Shift+Enter always inserts a newline (after a list-continuation check).
492
+ if (e.key === 'Enter' && e.shiftKey && !e.nativeEvent.isComposing) {
493
+ e.preventDefault()
494
+ const editor = editorRef.current
495
+ if (editor && !tryListContinuation(editor)) {
496
+ insertPlainNewline(editor)
497
+ }
498
+ return
499
+ }
500
+
501
+ // 3. Enter without Shift (skipping IME): under `submitOnEnter` it submits
502
+ // *unconditionally* — the send key must not turn into "another bullet" because
503
+ // of what the line above starts with; list continuation lives on Shift+Enter
504
+ // (branch 2.8). Without `submitOnEnter`, Enter is the newline key and continues.
505
+ if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing) {
506
+ e.preventDefault()
507
+ if (submitOnEnter) {
508
+ onSubmit?.(readSegmentsFromDOM())
509
+ return
510
+ }
511
+ const editor = editorRef.current
512
+ if (editor && !tryListContinuation(editor)) {
513
+ insertPlainNewline(editor)
514
+ }
515
+ return
516
+ }
517
+
518
+ // 4. Escape
519
+ if (e.key === 'Escape' && onEscape) {
520
+ onEscape()
521
+ return
522
+ }
523
+
524
+ // 4.5 Non-collapsed selection delete (Backspace/Delete across <a> boundaries)
525
+ if ((e.key === 'Backspace' || e.key === 'Delete') && !e.nativeEvent.isComposing) {
526
+ const editor = editorRef.current
527
+ if (editor) {
528
+ const offsets = getSelectionOffsets(editor)
529
+ if (offsets && offsets.start !== offsets.end) {
530
+ e.preventDefault()
531
+ const currentSegments = readSegmentsFromDOM()
532
+ pushUndo(currentSegments)
533
+ const newSegments = replaceTextRange(currentSegments, offsets.start, offsets.end, '')
534
+ applyEditResult(editor, { segments: newSegments, cursorOffset: offsets.start })
535
+ runTriggerDetection()
536
+ return
537
+ }
538
+ }
539
+ }
540
+
541
+ // 5. Backspace: check list prefix removal, then chip deletion
542
+ if (e.key === 'Backspace') {
543
+ const editor = editorRef.current
544
+ if (editor) {
545
+ const segments = readSegmentsFromDOM()
546
+ const cursorPos = getCursorOffset(editor)
547
+ if (markdownEnabled && cursorPos !== null) {
548
+ const result = removeListPrefix(segments, cursorPos)
549
+ if (result) {
550
+ e.preventDefault()
551
+ applyEditResult(editor, result)
552
+ runTriggerDetection()
553
+ return
554
+ }
555
+ }
556
+ }
557
+ if (handleChipBackspace()) {
558
+ e.preventDefault()
559
+ runTriggerDetection()
560
+ return
561
+ }
562
+ }
563
+
564
+ // 6. Delete (forward): delete chip as whole unit
565
+ if (e.key === 'Delete' && handleChipForwardDelete()) {
566
+ e.preventDefault()
567
+ runTriggerDetection()
568
+ return
569
+ }
570
+ },
571
+ [
572
+ activeTrigger,
573
+ suggestions,
574
+ suggestionsLoading,
575
+ suggestionsError,
576
+ selectedSuggestionIndex,
577
+ onSubmit,
578
+ submitOnEnter,
579
+ onEscape,
580
+ readSegmentsFromDOM,
581
+ onChange,
582
+ renderSegmentsToDOM,
583
+ markdownEnabled,
584
+ dismissTrigger,
585
+ handleChipBackspace,
586
+ handleChipForwardDelete,
587
+ autoResolveActiveTrigger,
588
+ runTriggerDetection,
589
+ selectSuggestionInternal,
590
+ pushUndo,
591
+ handleKeyDownForUndoRedo,
592
+ triggers,
593
+ buildInsertChip,
594
+ ],
595
+ )
596
+
597
+ return handleKeyDown
598
+ }
@@ -29,19 +29,11 @@ import { useCallback, useMemo, useRef, useState } from 'react'
29
29
  import type { Segment, ChipSegment, PromptAreaHandle } from './types.ts'
30
30
  import { segmentsToPlainText } from './prompt-area-engine.ts'
31
31
 
32
- // ---------------------------------------------------------------------------
33
- // Options
34
- // ---------------------------------------------------------------------------
35
-
36
32
  export type UsePromptAreaStateOptions = {
37
33
  /** Initial segment value. Defaults to `[]`. */
38
34
  initialValue?: Segment[]
39
35
  }
40
36
 
41
- // ---------------------------------------------------------------------------
42
- // Return type
43
- // ---------------------------------------------------------------------------
44
-
45
37
  export type PromptAreaBind = {
46
38
  /** Ref to attach to PromptArea — gives access to imperative methods. */
47
39
  ref: React.RefObject<PromptAreaHandle | null>
@@ -72,35 +64,28 @@ export type PromptAreaState = {
72
64
  insertChip: (chip: Omit<ChipSegment, 'type'>) => void
73
65
  }
74
66
 
75
- // ---------------------------------------------------------------------------
76
- // Hook
77
- // ---------------------------------------------------------------------------
78
-
79
67
  export function usePromptAreaState(options: UsePromptAreaStateOptions = {}): PromptAreaState {
80
68
  const { initialValue = [] } = options
81
69
 
82
70
  const [value, setValue] = useState<Segment[]>(initialValue)
83
71
  const ref = useRef<PromptAreaHandle>(null)
84
72
 
85
- // Derived
86
73
  const plainText = useMemo(() => segmentsToPlainText(value), [value])
87
74
 
88
75
  const isEmpty = useMemo(() => {
89
- if (value.length === 0) return true
76
+ if (value.length === 0) {
77
+ return true
78
+ }
90
79
  return value.every((seg) => seg.type === 'text' && seg.text.trim() === '')
91
80
  }, [value])
92
81
 
93
82
  const hasChips = useMemo(() => value.some((seg) => seg.type === 'chip'), [value])
94
83
 
95
- const chips = useMemo(
96
- () => value.filter((seg): seg is ChipSegment => seg.type === 'chip'),
97
- [value],
98
- )
84
+ const chips = useMemo(() => value.filter((seg): seg is ChipSegment => seg.type === 'chip'), [value])
99
85
 
100
86
  // Bind object — safe to spread onto <PromptArea>
101
87
  const bind = useMemo<PromptAreaBind>(() => ({ ref, value, onChange: setValue }), [value])
102
88
 
103
- // Actions that proxy to the imperative handle
104
89
  const clear = useCallback(() => {
105
90
  if (ref.current) {
106
91
  ref.current.clear()
@@ -112,10 +97,7 @@ export function usePromptAreaState(options: UsePromptAreaStateOptions = {}): Pro
112
97
  const focus = useCallback(() => ref.current?.focus(), [])
113
98
  const blur = useCallback(() => ref.current?.blur(), [])
114
99
 
115
- const insertChip = useCallback(
116
- (chip: Omit<ChipSegment, 'type'>) => ref.current?.insertChip(chip),
117
- [],
118
- )
100
+ const insertChip = useCallback((chip: Omit<ChipSegment, 'type'>) => ref.current?.insertChip(chip), [])
119
101
 
120
102
  return {
121
103
  bind,