@workerdeck/ui 0.23.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.
- package/build/{SessionPanel-DUt2VzXG.mjs → SessionPanel-DDgBkHsi.mjs} +2151 -3357
- package/build/SessionPanel-DDgBkHsi.mjs.map +1 -0
- package/build/SessionPanel-E_L_ldQ5.d.mts +207 -0
- package/build/format-B8ZM9LPy.d.mts +16 -0
- package/build/format.d.mts +8 -75
- package/build/format.mjs +2 -2
- package/build/index.d.mts +63 -1138
- package/build/index.mjs +4 -300
- package/build/index.mjs.map +1 -1
- package/build/scoped.css +15 -23
- package/build/{status-BE-zg88x.mjs → status-C0HSLZas.mjs} +14 -85
- package/build/status-C0HSLZas.mjs.map +1 -0
- package/build/workspace.d.mts +5 -126
- package/build/workspace.mjs +11 -143
- package/build/workspace.mjs.map +1 -1
- package/package.json +4 -4
- package/src/components/agent/CodeEditor.tsx +31 -103
- package/src/components/agent/Composer.tsx +114 -333
- package/src/components/agent/ContextDialog.tsx +20 -41
- package/src/components/agent/ContextRing.tsx +6 -28
- package/src/components/agent/Conversation.tsx +21 -33
- package/src/components/agent/EditorTabs.tsx +39 -67
- package/src/components/agent/EngineIcon.tsx +29 -72
- package/src/components/agent/EntryIcon.tsx +16 -0
- package/src/components/agent/FileCard.tsx +10 -16
- package/src/components/agent/FileTree.tsx +52 -125
- package/src/components/agent/FileViewer.tsx +30 -60
- package/src/components/agent/HostFilesDialog.tsx +40 -69
- package/src/components/agent/Loader.tsx +3 -24
- package/src/components/agent/McpDialog.tsx +64 -137
- package/src/components/agent/Message.tsx +2 -21
- package/src/components/agent/ModelSelect.tsx +33 -62
- package/src/components/agent/PermissionModeSelect.tsx +21 -79
- package/src/components/agent/PermissionPrompt.tsx +59 -63
- package/src/components/agent/ProjectIcon.tsx +228 -93
- package/src/components/agent/PromptTokenText.tsx +10 -16
- package/src/components/agent/QuestionPrompt.tsx +43 -64
- package/src/components/agent/Reasoning.tsx +13 -16
- package/src/components/agent/Response.tsx +3 -9
- package/src/components/agent/Scrubber.tsx +31 -80
- package/src/components/agent/SessionBrowser.tsx +64 -243
- package/src/components/agent/SessionEmptyState.tsx +10 -40
- package/src/components/agent/SessionInfoDialog.tsx +46 -78
- package/src/components/agent/SessionItem.tsx +53 -320
- package/src/components/agent/SessionList.tsx +23 -39
- package/src/components/agent/SessionPanel.tsx +315 -996
- package/src/components/agent/SessionStatusIcon.tsx +14 -28
- package/src/components/agent/SessionSteps.tsx +34 -187
- package/src/components/agent/SessionWorkspace.tsx +104 -219
- package/src/components/agent/SkillsDialog.tsx +35 -80
- package/src/components/agent/StatusBar.tsx +51 -136
- package/src/components/agent/SubagentStrip.tsx +12 -77
- package/src/components/agent/ToolCallCard.tsx +94 -135
- package/src/components/agent/Transcript.tsx +103 -1288
- package/src/components/agent/TranscriptItemView.tsx +155 -0
- package/src/components/agent/TranscriptRows.tsx +418 -0
- package/src/components/agent/UsageDialog.tsx +7 -29
- package/src/components/agent/UsageMeters.tsx +18 -73
- package/src/components/agent/pulse.tsx +9 -32
- package/src/components/agent/scrubber-marks.ts +33 -103
- package/src/components/agent/status.ts +1 -5
- package/src/components/agent/tool-result-fetch.tsx +3 -25
- package/src/components/agent/tool-result-image.tsx +32 -111
- package/src/components/agent/transcript-rows.ts +41 -118
- package/src/components/agent/transcript-variant.tsx +3 -80
- package/src/components/agent/use-height-epoch.ts +10 -24
- package/src/components/agent/use-path-links.ts +45 -63
- package/src/components/agent/use-subagent-frame.ts +106 -0
- package/src/components/agent/use-transcript-jumps.ts +49 -107
- package/src/components/prompt-area/animated-placeholder.tsx +6 -5
- package/src/components/prompt-area/clipboard-helpers.ts +26 -21
- package/src/components/prompt-area/cursor-helpers.ts +79 -37
- package/src/components/prompt-area/dom-helpers.ts +97 -69
- package/src/components/prompt-area/file-strip.tsx +90 -80
- package/src/components/prompt-area/html-to-markdown.ts +67 -46
- package/src/components/prompt-area/image-strip.tsx +8 -18
- package/src/components/prompt-area/index.ts +2 -15
- package/src/components/prompt-area/prompt-area-engine.ts +100 -115
- package/src/components/prompt-area/prompt-area-list-ops.ts +66 -59
- package/src/components/prompt-area/prompt-area.tsx +31 -38
- package/src/components/prompt-area/remove-button.tsx +3 -9
- package/src/components/prompt-area/segment-helpers.ts +4 -12
- package/src/components/prompt-area/trigger-popover.tsx +15 -27
- package/src/components/prompt-area/trigger-presets.ts +0 -10
- package/src/components/prompt-area/types.ts +3 -8
- package/src/components/prompt-area/use-chip-editing.ts +305 -0
- package/src/components/prompt-area/use-markdown-mode.ts +7 -17
- package/src/components/prompt-area/use-prompt-area-events.ts +80 -86
- package/src/components/prompt-area/use-prompt-area-keydown.ts +598 -0
- package/src/components/prompt-area/use-prompt-area-state.ts +5 -23
- package/src/components/prompt-area/use-prompt-area.ts +170 -896
- package/src/components/prompt-area/use-trigger-search.ts +21 -19
- package/src/components/terminal/PermissionPrompt.tsx +56 -59
- package/src/components/terminal/QuestionPrompt.tsx +28 -79
- package/src/components/terminal/StatusLine.tsx +13 -63
- package/src/components/terminal/TerminalTranscript.tsx +44 -160
- package/src/components/terminal/affordances.tsx +68 -98
- package/src/components/terminal/blocks.ts +20 -156
- package/src/components/terminal/diff.tsx +19 -67
- package/src/components/terminal/height.ts +163 -313
- package/src/components/terminal/image-box.ts +0 -44
- package/src/components/terminal/items.tsx +150 -305
- package/src/components/terminal/markdown.tsx +36 -95
- package/src/components/terminal/press.tsx +26 -53
- package/src/components/terminal/prompt.tsx +40 -170
- package/src/components/terminal/result-preview.ts +8 -56
- package/src/components/terminal/row.tsx +11 -98
- package/src/components/terminal/scrubber.tsx +108 -401
- package/src/components/terminal/surface.tsx +6 -54
- package/src/components/terminal/todos.ts +66 -0
- package/src/components/terminal/tool-run.ts +21 -192
- package/src/components/ui/AlertDialog.tsx +8 -23
- package/src/components/ui/Badge.tsx +18 -29
- package/src/components/ui/Button.tsx +7 -15
- package/src/components/ui/Card.tsx +2 -13
- package/src/components/ui/CodeBlock.tsx +10 -37
- package/src/components/ui/CopyButton.tsx +10 -23
- package/src/components/ui/Dialog.tsx +23 -36
- package/src/components/ui/Empty.tsx +6 -29
- package/src/components/ui/Input.tsx +14 -16
- package/src/components/ui/Menu.tsx +6 -18
- package/src/components/ui/PortalScope.tsx +1 -21
- package/src/components/ui/ProgressRing.tsx +8 -19
- package/src/components/ui/Select.tsx +18 -31
- package/src/components/ui/Sonner.tsx +1 -2
- package/src/components/ui/Spinner.tsx +1 -1
- package/src/components/ui/Splitter.tsx +30 -64
- package/src/components/ui/Textarea.tsx +2 -5
- package/src/components/ui/Tooltip.tsx +3 -13
- package/src/format.ts +0 -9
- package/src/index.ts +16 -113
- package/src/lib/clipboard.ts +8 -28
- package/src/lib/css.ts +8 -0
- package/src/lib/format.ts +101 -86
- package/src/lib/plan-request.ts +16 -0
- package/src/lib/status.ts +45 -95
- package/src/lib/tool-icon.ts +39 -47
- package/src/lib/utils.ts +1 -3
- package/src/styles/terminal.css +81 -49
- package/src/styles/theme.css +96 -32
- package/src/workspace.ts +1 -24
- package/build/SessionPanel-BobynUo4.d.mts +0 -669
- package/build/SessionPanel-DUt2VzXG.mjs.map +0 -1
- package/build/format-ljc3lKpA.d.mts +0 -59
- 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)
|
|
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,
|