@neurocode-ai/session-ui 1.18.8

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 (114) hide show
  1. package/package.json +71 -0
  2. package/src/components/apply-patch-file.test.ts +43 -0
  3. package/src/components/apply-patch-file.ts +78 -0
  4. package/src/components/basic-tool.css +358 -0
  5. package/src/components/basic-tool.stories.tsx +133 -0
  6. package/src/components/basic-tool.tsx +343 -0
  7. package/src/components/dock-prompt.stories.tsx +62 -0
  8. package/src/components/dock-prompt.tsx +23 -0
  9. package/src/components/file-media.tsx +291 -0
  10. package/src/components/file-search.tsx +72 -0
  11. package/src/components/file-ssr.tsx +197 -0
  12. package/src/components/file.css +46 -0
  13. package/src/components/file.tsx +1202 -0
  14. package/src/components/line-comment-annotations.tsx +637 -0
  15. package/src/components/line-comment-styles.ts +292 -0
  16. package/src/components/line-comment.stories.tsx +115 -0
  17. package/src/components/line-comment.tsx +439 -0
  18. package/src/components/markdown-cache.tsx +78 -0
  19. package/src/components/markdown-code-state.test.ts +32 -0
  20. package/src/components/markdown-code-state.ts +22 -0
  21. package/src/components/markdown-inline-code-kind.test.ts +46 -0
  22. package/src/components/markdown-inline-code-kind.ts +1915 -0
  23. package/src/components/markdown-preload.test.ts +18 -0
  24. package/src/components/markdown-shiki.worker.ts +104 -0
  25. package/src/components/markdown-stream.test.ts +194 -0
  26. package/src/components/markdown-stream.ts +110 -0
  27. package/src/components/markdown-worker-protocol.test.ts +81 -0
  28. package/src/components/markdown-worker-protocol.ts +48 -0
  29. package/src/components/markdown-worker-queue.test.ts +49 -0
  30. package/src/components/markdown-worker-queue.ts +64 -0
  31. package/src/components/markdown-worker-transport.test.ts +56 -0
  32. package/src/components/markdown-worker-transport.ts +41 -0
  33. package/src/components/markdown-worker.ts +122 -0
  34. package/src/components/markdown.css +377 -0
  35. package/src/components/markdown.stories.tsx +53 -0
  36. package/src/components/markdown.tsx +678 -0
  37. package/src/components/message-file.test.ts +63 -0
  38. package/src/components/message-file.ts +34 -0
  39. package/src/components/message-nav.css +127 -0
  40. package/src/components/message-nav.stories.tsx +7 -0
  41. package/src/components/message-nav.tsx +102 -0
  42. package/src/components/message-part-text.ts +3 -0
  43. package/src/components/message-part.css +1569 -0
  44. package/src/components/message-part.stories.tsx +7 -0
  45. package/src/components/message-part.test.ts +28 -0
  46. package/src/components/message-part.tsx +2662 -0
  47. package/src/components/session-diff.test.ts +135 -0
  48. package/src/components/session-diff.ts +145 -0
  49. package/src/components/session-retry.tsx +74 -0
  50. package/src/components/session-review.css +247 -0
  51. package/src/components/session-review.stories.tsx +7 -0
  52. package/src/components/session-review.tsx +657 -0
  53. package/src/components/session-turn.css +241 -0
  54. package/src/components/session-turn.stories.tsx +7 -0
  55. package/src/components/session-turn.tsx +543 -0
  56. package/src/components/shell-submessage-motion.stories.tsx +346 -0
  57. package/src/components/shell-submessage.css +23 -0
  58. package/src/components/timeline-playground.stories.tsx +2090 -0
  59. package/src/components/tool-count-label.css +57 -0
  60. package/src/components/tool-count-label.tsx +58 -0
  61. package/src/components/tool-count-summary.css +102 -0
  62. package/src/components/tool-count-summary.stories.tsx +238 -0
  63. package/src/components/tool-count-summary.tsx +52 -0
  64. package/src/components/tool-error-card.css +91 -0
  65. package/src/components/tool-error-card.stories.tsx +92 -0
  66. package/src/components/tool-error-card.tsx +157 -0
  67. package/src/components/tool-status-title.css +89 -0
  68. package/src/components/tool-status-title.tsx +133 -0
  69. package/src/context/data.tsx +64 -0
  70. package/src/context/index.ts +1 -0
  71. package/src/pierre/comment-hover.ts +83 -0
  72. package/src/pierre/commented-lines.ts +91 -0
  73. package/src/pierre/diff-selection.ts +71 -0
  74. package/src/pierre/file-find.ts +485 -0
  75. package/src/pierre/file-runtime.ts +114 -0
  76. package/src/pierre/file-selection.ts +85 -0
  77. package/src/pierre/index.ts +186 -0
  78. package/src/pierre/media.ts +110 -0
  79. package/src/pierre/selection-bridge.ts +132 -0
  80. package/src/pierre/virtualizer.ts +100 -0
  81. package/src/pierre/worker.ts +52 -0
  82. package/src/styles/index.css +14 -0
  83. package/src/v2/components/attachment-card-v2.css +70 -0
  84. package/src/v2/components/attachment-card-v2.tsx +33 -0
  85. package/src/v2/components/basic-tool-v2.css +163 -0
  86. package/src/v2/components/basic-tool-v2.stories.tsx +137 -0
  87. package/src/v2/components/basic-tool-v2.tsx +139 -0
  88. package/src/v2/components/comment-card-v2.tsx +64 -0
  89. package/src/v2/components/line-comment-annotations-v2.tsx +220 -0
  90. package/src/v2/components/prompt-input/attachments.ts +266 -0
  91. package/src/v2/components/prompt-input/index.tsx +706 -0
  92. package/src/v2/components/prompt-input/interaction.ts +482 -0
  93. package/src/v2/components/prompt-input/machine.test.ts +164 -0
  94. package/src/v2/components/prompt-input/machine.ts +261 -0
  95. package/src/v2/components/prompt-input/prompt-input.stories.tsx +221 -0
  96. package/src/v2/components/prompt-input/store.test.ts +116 -0
  97. package/src/v2/components/prompt-input/store.ts +152 -0
  98. package/src/v2/components/prompt-input/types.ts +106 -0
  99. package/src/v2/components/session-file-panel-v2.tsx +43 -0
  100. package/src/v2/components/session-progress-indicator-v2.css +875 -0
  101. package/src/v2/components/session-progress-indicator-v2.stories.tsx +66 -0
  102. package/src/v2/components/session-progress-indicator-v2.tsx +32 -0
  103. package/src/v2/components/session-review-empty-changes-v2.tsx +17 -0
  104. package/src/v2/components/session-review-empty-no-git-v2.tsx +28 -0
  105. package/src/v2/components/session-review-file-preview-v2-virtualize.test.ts +13 -0
  106. package/src/v2/components/session-review-file-preview-v2-virtualize.ts +5 -0
  107. package/src/v2/components/session-review-file-preview-v2.tsx +290 -0
  108. package/src/v2/components/session-review-v2.css +458 -0
  109. package/src/v2/components/session-review-v2.tsx +340 -0
  110. package/src/v2/components/tool-error-card-v2.css +200 -0
  111. package/src/v2/components/tool-error-card-v2.stories.tsx +91 -0
  112. package/src/v2/components/tool-error-card-v2.tsx +166 -0
  113. package/sst-env.d.ts +10 -0
  114. package/tsconfig.json +19 -0
@@ -0,0 +1,482 @@
1
+ import { createEffect, on, type Accessor } from "solid-js"
2
+ import { createStore, reconcile } from "solid-js/store"
3
+ import { useFilteredList } from "@neurocode-ai/ui/hooks"
4
+ import { createPromptInputV2Attachments, type PromptInputV2AttachmentConfig } from "./attachments"
5
+ import { createPromptInputV2Store, type PromptInputV2StoreInput } from "./store"
6
+ import type {
7
+ PromptInputV2Attachment,
8
+ PromptInputV2Comment,
9
+ PromptInputV2History,
10
+ PromptInputV2HistoryEntry,
11
+ PromptInputV2Option,
12
+ PromptInputV2PersistedState,
13
+ PromptInputV2Suggestion,
14
+ } from "./types"
15
+ import {
16
+ createPromptInputV2InteractionState,
17
+ transitionPromptInputV2,
18
+ type PromptInputV2InteractionCommand,
19
+ type PromptInputV2InteractionEvent,
20
+ } from "./machine"
21
+
22
+ export type PromptInputV2SelectControl = {
23
+ options: Accessor<PromptInputV2Option[]>
24
+ current: Accessor<string>
25
+ onSelect: (id: string) => void
26
+ keybind?: Accessor<string[]>
27
+ }
28
+
29
+ export type PromptInputV2ViewConfig = {
30
+ placeholder?: Accessor<string>
31
+ add?: {
32
+ onAttach: () => void
33
+ }
34
+ agent?: PromptInputV2SelectControl
35
+ model?: PromptInputV2SelectControl
36
+ variant?: PromptInputV2SelectControl
37
+ submit: {
38
+ stopping: Accessor<boolean>
39
+ working?: Accessor<boolean>
40
+ onSubmit: () => void
41
+ onStop: () => void
42
+ }
43
+ shell?: {
44
+ onOpen: () => void
45
+ onClose: () => void
46
+ }
47
+ onKeyDown?: (event: KeyboardEvent) => void
48
+ onPaste?: (event: ClipboardEvent) => void
49
+ onDrop?: (event: DragEvent) => void
50
+ }
51
+
52
+ export function createPromptInputV2State() {
53
+ return createStore(createPromptInputV2InteractionState())
54
+ }
55
+
56
+ export function createPromptInputV2Controller(input: {
57
+ store: PromptInputV2StoreInput
58
+ state?: ReturnType<typeof createPromptInputV2State>
59
+ identity?: Accessor<unknown>
60
+ history?: PromptInputV2History
61
+ commands: Accessor<PromptInputV2Suggestion[]>
62
+ context: Accessor<PromptInputV2Suggestion[]>
63
+ searchContextFiles: (query: string) => PromptInputV2Suggestion[] | Promise<PromptInputV2Suggestion[]>
64
+ openAttachment?: (attachment: PromptInputV2Attachment) => void
65
+ openContext?: (key: string) => void
66
+ onContextRemove?: (item: PromptInputV2Comment) => void
67
+ onEditor?: (element: HTMLElement) => void
68
+ onSuggestionSelect?: (item: PromptInputV2Suggestion) => (() => void) | void
69
+ view: PromptInputV2ViewConfig
70
+ attachments?: PromptInputV2AttachmentConfig
71
+ }) {
72
+ let editor: HTMLElement | undefined
73
+ let fileInput: HTMLInputElement | undefined
74
+ const draft = createPromptInputV2Store(input.store)
75
+ const [state, setState] = input.state ?? createPromptInputV2State()
76
+ if (input.identity) {
77
+ createEffect(on(input.identity, () => setState(reconcile(createPromptInputV2InteractionState())), { defer: true }))
78
+ }
79
+ function addPart(part: PromptInputV2PersistedState["prompt"][number]) {
80
+ if (part.type === "image") return false
81
+ if (part.type === "file" || part.type === "agent") {
82
+ draft.addMention(part)
83
+ return true
84
+ }
85
+ draft.addText(part.content)
86
+ return true
87
+ }
88
+ const attachments = input.attachments
89
+ ? createPromptInputV2Attachments({
90
+ ...input.attachments,
91
+ capture: () => ({
92
+ current: () => draft.state.prompt,
93
+ cursor: () => draft.state.cursor,
94
+ set: draft.setPrompt,
95
+ }),
96
+ editor: () => editor,
97
+ focusEditor: () => editor?.focus(),
98
+ addPart,
99
+ setDraggingType: (type) => dispatch({ type: type ? "drag.enter" : "drag.leave" }),
100
+ })
101
+ : undefined
102
+ const attach = () => {
103
+ if (!attachments) {
104
+ input.view.add?.onAttach()
105
+ return
106
+ }
107
+ attachments.pick(() => fileInput?.click())
108
+ }
109
+ const contextList = useFilteredList<PromptInputV2Suggestion>({
110
+ items: async (query) => {
111
+ const fixed = input.context().filter((item) => item.kind !== "file")
112
+ const recent = input.context().filter((item) => item.kind === "file" && item.recent)
113
+ if (!query.trim()) return [...fixed, ...recent]
114
+ const seen = new Set(recent.map((item) => item.id))
115
+ const files = (await input.searchContextFiles(query)).filter((item) => !seen.has(item.id))
116
+ return [...fixed, ...recent, ...files]
117
+ },
118
+ key: (item) => item.id,
119
+ filterKeys: ["label"],
120
+ skipFilter: (item) => item.kind === "file" && !item.recent,
121
+ groupBy: (item) => {
122
+ if (item.kind === "reference") return "reference"
123
+ if (item.kind === "agent") return "agent"
124
+ if (item.kind === "resource") return "resource"
125
+ if (item.recent) return "recent"
126
+ return "file"
127
+ },
128
+ sortGroupsBy: (a, b) => {
129
+ const order = ["reference", "agent", "resource", "recent", "file"]
130
+ return order.indexOf(a.category) - order.indexOf(b.category)
131
+ },
132
+ })
133
+ const commandList = useFilteredList<PromptInputV2Suggestion>({
134
+ items: () => input.commands(),
135
+ key: (item) => item.id,
136
+ filterKeys: ["trigger", "title"],
137
+ })
138
+ const list = () => (state.popover.type === "context" ? contextList : commandList)
139
+ const suggestions = () => list().flat()
140
+
141
+ const execute = (command: PromptInputV2InteractionCommand) => {
142
+ if (command.type === "draft.setText") {
143
+ draft.setText(command.value)
144
+ return
145
+ }
146
+ if (command.type === "mention.add") {
147
+ if (command.item.mention) draft.addMention(command.item.mention)
148
+ return
149
+ }
150
+ if (command.type === "popover.filter") {
151
+ ;(command.popover === "command" ? commandList : contextList).onInput(command.query)
152
+ return
153
+ }
154
+ if (command.type === "suggestion.select") {
155
+ const item = suggestions().find((entry) => entry.id === command.id)
156
+ if (item) dispatch({ type: "popover.select", item })
157
+ return
158
+ }
159
+ if (command.type === "focus.editor") requestAnimationFrame(() => editor?.focus())
160
+ }
161
+
162
+ function dispatch(event: PromptInputV2InteractionEvent) {
163
+ const mode = state.mode
164
+ const result = transitionPromptInputV2(state, event, draft.state)
165
+ const action = event.type === "popover.select" ? input.onSuggestionSelect?.(event.item) : undefined
166
+ if (event.type === "popover.select") {
167
+ if (!action || state.popover.type !== "command-menu") result.commands.forEach(execute)
168
+ if (action && event.item.kind === "command" && state.popover.type !== "command-menu") {
169
+ draft.setPrompt(
170
+ draft.state.prompt.filter((part): part is PromptInputV2Attachment => part.type === "image"),
171
+ 0,
172
+ )
173
+ }
174
+ }
175
+ setState(reconcile(result.state))
176
+ if (event.type !== "popover.select") result.commands.forEach(execute)
177
+ if (mode !== result.state.mode) {
178
+ if (result.state.mode === "shell") input.view.shell?.onOpen()
179
+ if (result.state.mode === "normal") input.view.shell?.onClose()
180
+ }
181
+ if (event.type === "popover.select") {
182
+ if (!action) return result.handled
183
+ action()
184
+ }
185
+ return result.handled
186
+ }
187
+
188
+ const onKeyDown = (event: KeyboardEvent) => {
189
+ if (
190
+ state.mode === "normal" &&
191
+ (event.metaKey || event.ctrlKey) &&
192
+ !event.altKey &&
193
+ !event.shiftKey &&
194
+ event.key.toLowerCase() === "u"
195
+ ) {
196
+ event.preventDefault()
197
+ attach()
198
+ return true
199
+ }
200
+ const handled = dispatch({
201
+ type: "key.down",
202
+ key: event.key,
203
+ ctrl: event.ctrlKey && !event.metaKey && !event.altKey && !event.shiftKey,
204
+ composing: event.isComposing,
205
+ ids: suggestions().map((item) => item.id),
206
+ empty: draft.state.prompt.every((part) => !("content" in part) || part.content.length === 0),
207
+ })
208
+ if (handled) event.preventDefault()
209
+ if (handled && event.key !== "Enter" && event.key !== "Tab" && state.popover.type !== "closed") {
210
+ const activeID = state.popover.activeID ?? ""
211
+ requestAnimationFrame(() =>
212
+ document.querySelector(`[data-suggestion-id="${CSS.escape(activeID)}"]`)?.scrollIntoView({ block: "nearest" }),
213
+ )
214
+ }
215
+ if (handled) return true
216
+ const stop =
217
+ input.view.submit.working?.() &&
218
+ ((event.ctrlKey && !event.metaKey && !event.altKey && !event.shiftKey && event.key.toLowerCase() === "g") ||
219
+ event.key === "Escape")
220
+ if (stop) {
221
+ event.preventDefault()
222
+ input.view.submit.onStop()
223
+ return true
224
+ }
225
+ if (
226
+ !event.altKey &&
227
+ !event.ctrlKey &&
228
+ !event.metaKey &&
229
+ (event.key === "ArrowUp" || event.key === "ArrowDown") &&
230
+ navigateHistory(event.key === "ArrowUp" ? "up" : "down")
231
+ ) {
232
+ event.preventDefault()
233
+ return true
234
+ }
235
+ input.view.onKeyDown?.(event)
236
+ return event.defaultPrevented
237
+ }
238
+
239
+ createEffect(() => {
240
+ if (state.popover.type === "closed") return
241
+ const ids = suggestions().map((item) => item.id)
242
+ if (state.popover.activeID ? ids.includes(state.popover.activeID) : ids.length === 0) return
243
+ dispatch({ type: "popover.results", ids })
244
+ })
245
+
246
+ const restoreFocus = (cursor = draft.state.cursor ?? promptLength(draft.state.prompt)) => {
247
+ requestAnimationFrame(() => {
248
+ editor?.focus()
249
+ setEditorCursor(editor, cursor)
250
+ })
251
+ }
252
+
253
+ const applyHistory = (entry: PromptInputV2HistoryEntry, position: "start" | "end") => {
254
+ input.history?.restore?.(entry.metadata)
255
+ const cursor = position === "start" ? 0 : promptLength(entry.prompt)
256
+ draft.setPrompt(clonePrompt(entry.prompt), cursor)
257
+ restoreFocus(cursor)
258
+ }
259
+ const navigateHistory = (direction: "up" | "down") => {
260
+ if (!input.history || !editor) return false
261
+ const selection = window.getSelection()
262
+ if (!selection?.isCollapsed || !editor.contains(selection.anchorNode)) return false
263
+ const text = draft.state.prompt.map((part) => ("content" in part ? part.content : "")).join("")
264
+ if (!canNavigateHistory(direction, text, editorCursor(editor), state.historyIndex >= 0)) return false
265
+ const entries = input.history.entries(state.mode)
266
+ if (direction === "up") {
267
+ if (entries.length === 0 || state.historyIndex >= entries.length - 1) return false
268
+ if (state.historyIndex === -1) {
269
+ setState("savedHistory", {
270
+ prompt: clonePrompt(draft.state.prompt),
271
+ metadata: input.history.capture?.(),
272
+ })
273
+ }
274
+ const index = state.historyIndex + 1
275
+ setState("historyIndex", index)
276
+ applyHistory(entries[index]!, "start")
277
+ return true
278
+ }
279
+ if (state.historyIndex < 0) return false
280
+ if (state.historyIndex > 0) {
281
+ const index = state.historyIndex - 1
282
+ setState("historyIndex", index)
283
+ applyHistory(entries[index]!, "end")
284
+ return true
285
+ }
286
+ const saved = state.savedHistory ?? { prompt: [{ type: "text", content: "", start: 0, end: 0 }] }
287
+ setState({ historyIndex: -1, savedHistory: undefined })
288
+ applyHistory(saved, "end")
289
+ return true
290
+ }
291
+
292
+ return {
293
+ state,
294
+ view: input.view,
295
+ suggestions,
296
+ dispatch,
297
+ onKeyDown,
298
+ value() {
299
+ return draft.state.prompt.map((part) => ("content" in part ? part.content : "")).join("")
300
+ },
301
+ parts() {
302
+ return draft.state.prompt
303
+ },
304
+ addPart,
305
+ contextItem(id: string) {
306
+ return draft.state.context.items.find((item) => item.key === id)
307
+ },
308
+ comments() {
309
+ return draft.state.context.items.filter((item) => !!item.comment?.trim())
310
+ },
311
+ attachments(): PromptInputV2Attachment[] {
312
+ return draft.state.prompt.filter((part): part is PromptInputV2Attachment => part.type === "image")
313
+ },
314
+ toggleContext(id: string) {
315
+ dispatch({ type: "context.active", id })
316
+ input.openContext?.(id)
317
+ },
318
+ removeContext(id: string) {
319
+ const item = draft.state.context.items.find((entry) => entry.key === id)
320
+ if (item) input.onContextRemove?.(item)
321
+ draft.removeContext(id)
322
+ if (state.activeContextID === id) dispatch({ type: "context.active", id })
323
+ },
324
+ openAttachment(attachment: PromptInputV2Attachment) {
325
+ input.openAttachment?.(attachment)
326
+ },
327
+ removeAttachment(id: string) {
328
+ draft.removeAttachment(id)
329
+ },
330
+ canSubmit() {
331
+ const persisted = draft.state
332
+ if (persisted.prompt.some((part) => part.type === "image")) return true
333
+ if (persisted.context.items.some((item) => !!item.comment?.trim())) return true
334
+ return persisted.prompt.some((part) => "content" in part && !!part.content.trim())
335
+ },
336
+ setEditor(element: HTMLElement) {
337
+ editor = element
338
+ input.onEditor?.(element)
339
+ },
340
+ restoreFocus,
341
+ onInput(value: string, prompt?: PromptInputV2PersistedState["prompt"], cursor?: number) {
342
+ if (prompt) draft.setPrompt(prompt, cursor)
343
+ dispatch({ type: "input.changed", value, persist: !prompt })
344
+ },
345
+ onCursor(cursor: number) {
346
+ draft.setCursor(cursor)
347
+ },
348
+ openCommands() {
349
+ dispatch({ type: "commands.open" })
350
+ },
351
+ openContext() {
352
+ dispatch({ type: "context.open" })
353
+ },
354
+ openShell() {
355
+ dispatch({ type: "mode.shell" })
356
+ },
357
+ closeShell() {
358
+ dispatch({ type: "mode.normal" })
359
+ },
360
+ submit() {
361
+ input.view.submit.onSubmit()
362
+ dispatch({ type: "popover.close" })
363
+ },
364
+ stop() {
365
+ input.view.submit.onStop()
366
+ },
367
+ addHistory(prompt: PromptInputV2PersistedState["prompt"], mode: "normal" | "shell") {
368
+ input.history?.add(prompt, mode)
369
+ setState({ historyIndex: -1, savedHistory: undefined })
370
+ },
371
+ resetHistory() {
372
+ setState({ historyIndex: -1, savedHistory: undefined })
373
+ },
374
+ onPaste(event: ClipboardEvent) {
375
+ const clipboard = event.clipboardData
376
+ if (
377
+ attachments &&
378
+ (Array.from(clipboard?.items ?? []).some((item) => item.kind === "file") || !clipboard?.getData("text/plain"))
379
+ ) {
380
+ void attachments.handlePaste(event)
381
+ return
382
+ }
383
+ input.view.onPaste?.(event)
384
+ if (event.defaultPrevented) return
385
+ const text = clipboard?.getData("text/plain")
386
+ if (!text) return
387
+ event.preventDefault()
388
+ if (typeof document.execCommand === "function" && document.execCommand("insertText", false, text)) return
389
+ const target = event.currentTarget
390
+ const selection = window.getSelection()
391
+ if (!(target instanceof HTMLElement) || !selection?.rangeCount || !target.contains(selection.anchorNode)) return
392
+ const range = selection.getRangeAt(0)
393
+ range.deleteContents()
394
+ const node = document.createTextNode(text)
395
+ range.insertNode(node)
396
+ range.setStartAfter(node)
397
+ range.collapse(true)
398
+ selection.removeAllRanges()
399
+ selection.addRange(range)
400
+ target.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertFromPaste", data: text }))
401
+ },
402
+ onDragEnter(event: DragEvent) {
403
+ event.preventDefault()
404
+ dispatch({ type: "drag.enter" })
405
+ },
406
+ onDragOver(event: DragEvent) {
407
+ event.preventDefault()
408
+ },
409
+ onDragLeave() {
410
+ dispatch({ type: "drag.leave" })
411
+ },
412
+ onDrop(event: DragEvent) {
413
+ event.preventDefault()
414
+ dispatch({ type: "drag.leave" })
415
+ if (attachments) {
416
+ event.stopPropagation()
417
+ void attachments.handleDrop(event)
418
+ return
419
+ }
420
+ input.view.onDrop?.(event)
421
+ },
422
+ attach,
423
+ setFileInput(element: HTMLInputElement) {
424
+ fileInput = element
425
+ },
426
+ addAttachments(files: File[]) {
427
+ if (attachments) void attachments.addAttachments(files)
428
+ },
429
+ setQuery(value: string) {
430
+ dispatch({ type: "popover.query", value })
431
+ },
432
+ }
433
+ }
434
+
435
+ export type PromptInputV2Interaction = ReturnType<typeof createPromptInputV2Controller>
436
+
437
+ function canNavigateHistory(direction: "up" | "down", text: string, cursor: number, inHistory: boolean) {
438
+ const position = Math.max(0, Math.min(cursor, text.length))
439
+ if (inHistory) return position === 0 || position === text.length
440
+ if (direction === "up") return position === 0 && text.length === 0
441
+ return position === text.length
442
+ }
443
+
444
+ function clonePrompt(prompt: PromptInputV2PersistedState["prompt"]): PromptInputV2PersistedState["prompt"] {
445
+ return prompt.map((part) =>
446
+ part.type === "file" ? { ...part, selection: part.selection ? { ...part.selection } : undefined } : { ...part },
447
+ )
448
+ }
449
+
450
+ function promptLength(prompt: PromptInputV2PersistedState["prompt"]) {
451
+ return prompt.reduce((length, part) => length + ("content" in part ? part.content.length : 0), 0)
452
+ }
453
+
454
+ function editorCursor(editor: HTMLElement) {
455
+ const selection = window.getSelection()
456
+ if (!selection?.rangeCount || !editor.contains(selection.anchorNode)) return editor.textContent?.length ?? 0
457
+ const range = selection.getRangeAt(0).cloneRange()
458
+ range.selectNodeContents(editor)
459
+ range.setEnd(selection.anchorNode!, selection.anchorOffset)
460
+ return range.toString().length
461
+ }
462
+
463
+ function setEditorCursor(editor: HTMLElement | undefined, cursor: number) {
464
+ if (!editor) return
465
+ const walker = document.createTreeWalker(editor, NodeFilter.SHOW_TEXT)
466
+ let remaining = cursor
467
+ let node = walker.nextNode()
468
+ while (node) {
469
+ const length = node.textContent?.length ?? 0
470
+ if (remaining <= length) {
471
+ const range = document.createRange()
472
+ range.setStart(node, remaining)
473
+ range.collapse(true)
474
+ const selection = window.getSelection()
475
+ selection?.removeAllRanges()
476
+ selection?.addRange(range)
477
+ return
478
+ }
479
+ remaining -= length
480
+ node = walker.nextNode()
481
+ }
482
+ }
@@ -0,0 +1,164 @@
1
+ import { describe, expect, test } from "bun:test"
2
+ import type { PromptInputV2PersistedState, PromptInputV2Suggestion } from "./types"
3
+ import { createPromptInputV2InteractionState, transitionPromptInputV2 } from "./machine"
4
+
5
+ const command: PromptInputV2Suggestion = {
6
+ id: "review",
7
+ kind: "command",
8
+ label: "/review",
9
+ }
10
+
11
+ function persisted(value = ""): PromptInputV2PersistedState {
12
+ return {
13
+ prompt: [{ type: "text", content: value, start: 0, end: value.length }],
14
+ cursor: value.length,
15
+ context: { items: [] },
16
+ }
17
+ }
18
+
19
+ describe("prompt input v2 interaction machine", () => {
20
+ test("opens inline commands only when slash is the entire prompt", () => {
21
+ const state = createPromptInputV2InteractionState()
22
+ const open = transitionPromptInputV2(state, { type: "input.changed", value: "/re" }, persisted())
23
+ const closed = transitionPromptInputV2(state, { type: "input.changed", value: "explain /re" }, persisted())
24
+
25
+ expect(open.state.popover).toEqual({ type: "command-inline", query: "re" })
26
+ expect(closed.state.popover).toEqual({ type: "closed" })
27
+ })
28
+
29
+ test("completes nested slash command names", () => {
30
+ const open = transitionPromptInputV2(
31
+ createPromptInputV2InteractionState(),
32
+ { type: "input.changed", value: "/review/" },
33
+ persisted(),
34
+ )
35
+ const item = { ...command, label: "/review/nested" }
36
+ const selected = transitionPromptInputV2(open.state, { type: "popover.select", item }, persisted("/review/"))
37
+
38
+ expect(open.state.popover).toEqual({ type: "command-inline", query: "review/" })
39
+ expect(selected.commands).toContainEqual({ type: "draft.setText", value: "/review/nested " })
40
+ })
41
+
42
+ test("opens context completion at the cursor", () => {
43
+ const value = "alpha @sr omega"
44
+ const input = persisted(value)
45
+ input.cursor = 9
46
+
47
+ const result = transitionPromptInputV2(
48
+ createPromptInputV2InteractionState(),
49
+ { type: "input.changed", value, persist: false },
50
+ input,
51
+ )
52
+
53
+ expect(result.state.popover).toEqual({ type: "context", query: "sr" })
54
+ })
55
+
56
+ test("enters shell mode from an initial exclamation mark", () => {
57
+ const result = transitionPromptInputV2(
58
+ createPromptInputV2InteractionState(),
59
+ { type: "input.changed", value: "!", persist: false },
60
+ persisted("!"),
61
+ )
62
+
63
+ expect(result.state.mode).toBe("shell")
64
+ expect(result.commands).toContainEqual({ type: "draft.setText", value: "" })
65
+ })
66
+
67
+ test("leaves shell mode with escape", () => {
68
+ const state = { ...createPromptInputV2InteractionState(), mode: "shell" as const }
69
+ const result = transitionPromptInputV2(
70
+ state,
71
+ { type: "key.down", key: "Escape", ctrl: false, composing: false, ids: [] },
72
+ persisted(),
73
+ )
74
+
75
+ expect(result.state.mode).toBe("normal")
76
+ expect(result.handled).toBeTrue()
77
+ })
78
+
79
+ test("leaves shell mode with backspace when empty", () => {
80
+ const state = { ...createPromptInputV2InteractionState(), mode: "shell" as const }
81
+ const result = transitionPromptInputV2(
82
+ state,
83
+ { type: "key.down", key: "Backspace", ctrl: false, composing: false, ids: [], empty: true },
84
+ persisted(),
85
+ )
86
+
87
+ expect(result.state.mode).toBe("normal")
88
+ expect(result.handled).toBeTrue()
89
+ })
90
+
91
+ test("closes a popover with ctrl-g before stopping a run", () => {
92
+ const state = {
93
+ ...createPromptInputV2InteractionState(),
94
+ popover: { type: "context" as const, query: "", activeID: "first" },
95
+ }
96
+ const result = transitionPromptInputV2(
97
+ state,
98
+ { type: "key.down", key: "g", ctrl: true, composing: false, ids: ["first"] },
99
+ persisted(),
100
+ )
101
+
102
+ expect(result.state.popover).toEqual({ type: "closed" })
103
+ expect(result.handled).toBeTrue()
104
+ })
105
+
106
+ test("opens the searchable command menu for a populated draft", () => {
107
+ const result = transitionPromptInputV2(
108
+ createPromptInputV2InteractionState(),
109
+ { type: "commands.open" },
110
+ persisted("existing text"),
111
+ )
112
+
113
+ expect(result.state.popover).toEqual({ type: "command-menu", query: "" })
114
+ expect(result.state.focus).toBe("command-search")
115
+ })
116
+
117
+ test("prepends a menu command and preserves existing text as arguments", () => {
118
+ const open = transitionPromptInputV2(
119
+ createPromptInputV2InteractionState(),
120
+ { type: "commands.open" },
121
+ persisted("existing text"),
122
+ )
123
+ const selected = transitionPromptInputV2(
124
+ open.state,
125
+ { type: "popover.select", item: command },
126
+ persisted("existing text"),
127
+ )
128
+
129
+ expect(selected.commands).toContainEqual({ type: "draft.setText", value: "/review existing text" })
130
+ expect(selected.state.popover).toEqual({ type: "closed" })
131
+ })
132
+
133
+ test("stores selected context files as prompt file parts", () => {
134
+ const item: PromptInputV2Suggestion = {
135
+ id: "src/index.ts",
136
+ kind: "file",
137
+ label: "index.ts",
138
+ path: "src/index.ts",
139
+ }
140
+ const state = {
141
+ ...createPromptInputV2InteractionState(),
142
+ popover: { type: "context" as const, query: "index" },
143
+ }
144
+
145
+ const selected = transitionPromptInputV2(state, { type: "popover.select", item }, persisted("@index"))
146
+
147
+ expect(selected.commands).toContainEqual({ type: "mention.add", item })
148
+ })
149
+
150
+ test("loops active popover items with arrow keys", () => {
151
+ const state = {
152
+ ...createPromptInputV2InteractionState(),
153
+ popover: { type: "context" as const, query: "", activeID: "second" },
154
+ }
155
+ const result = transitionPromptInputV2(
156
+ state,
157
+ { type: "key.down", key: "ArrowDown", ctrl: false, composing: false, ids: ["first", "second"] },
158
+ persisted(),
159
+ )
160
+
161
+ expect(result.state.popover).toEqual({ type: "context", query: "", activeID: "first" })
162
+ expect(result.handled).toBeTrue()
163
+ })
164
+ })