@milkdown/crepe 7.20.0 → 7.21.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 (173) hide show
  1. package/lib/cjs/builder.js +1 -0
  2. package/lib/cjs/builder.js.map +1 -1
  3. package/lib/cjs/feature/ai/index.js +1492 -0
  4. package/lib/cjs/feature/ai/index.js.map +1 -0
  5. package/lib/cjs/feature/block-edit/index.js +1 -0
  6. package/lib/cjs/feature/block-edit/index.js.map +1 -1
  7. package/lib/cjs/feature/code-mirror/index.js +1 -0
  8. package/lib/cjs/feature/code-mirror/index.js.map +1 -1
  9. package/lib/cjs/feature/cursor/index.js +1 -0
  10. package/lib/cjs/feature/cursor/index.js.map +1 -1
  11. package/lib/cjs/feature/image-block/index.js +1 -0
  12. package/lib/cjs/feature/image-block/index.js.map +1 -1
  13. package/lib/cjs/feature/latex/index.js +2 -0
  14. package/lib/cjs/feature/latex/index.js.map +1 -1
  15. package/lib/cjs/feature/link-tooltip/index.js +1 -0
  16. package/lib/cjs/feature/link-tooltip/index.js.map +1 -1
  17. package/lib/cjs/feature/list-item/index.js +1 -0
  18. package/lib/cjs/feature/list-item/index.js.map +1 -1
  19. package/lib/cjs/feature/placeholder/index.js +1 -0
  20. package/lib/cjs/feature/placeholder/index.js.map +1 -1
  21. package/lib/cjs/feature/table/index.js +1 -0
  22. package/lib/cjs/feature/table/index.js.map +1 -1
  23. package/lib/cjs/feature/toolbar/index.js +488 -3
  24. package/lib/cjs/feature/toolbar/index.js.map +1 -1
  25. package/lib/cjs/feature/top-bar/index.js +1 -0
  26. package/lib/cjs/feature/top-bar/index.js.map +1 -1
  27. package/lib/cjs/index.js +1424 -25
  28. package/lib/cjs/index.js.map +1 -1
  29. package/lib/cjs/llm-providers/anthropic/index.js +147 -0
  30. package/lib/cjs/llm-providers/anthropic/index.js.map +1 -0
  31. package/lib/cjs/llm-providers/openai/index.js +138 -0
  32. package/lib/cjs/llm-providers/openai/index.js.map +1 -0
  33. package/lib/esm/builder.js +1 -0
  34. package/lib/esm/builder.js.map +1 -1
  35. package/lib/esm/feature/ai/index.js +1487 -0
  36. package/lib/esm/feature/ai/index.js.map +1 -0
  37. package/lib/esm/feature/block-edit/index.js +1 -0
  38. package/lib/esm/feature/block-edit/index.js.map +1 -1
  39. package/lib/esm/feature/code-mirror/index.js +1 -0
  40. package/lib/esm/feature/code-mirror/index.js.map +1 -1
  41. package/lib/esm/feature/cursor/index.js +1 -0
  42. package/lib/esm/feature/cursor/index.js.map +1 -1
  43. package/lib/esm/feature/image-block/index.js +1 -0
  44. package/lib/esm/feature/image-block/index.js.map +1 -1
  45. package/lib/esm/feature/latex/index.js +2 -0
  46. package/lib/esm/feature/latex/index.js.map +1 -1
  47. package/lib/esm/feature/link-tooltip/index.js +1 -0
  48. package/lib/esm/feature/link-tooltip/index.js.map +1 -1
  49. package/lib/esm/feature/list-item/index.js +1 -0
  50. package/lib/esm/feature/list-item/index.js.map +1 -1
  51. package/lib/esm/feature/placeholder/index.js +1 -0
  52. package/lib/esm/feature/placeholder/index.js.map +1 -1
  53. package/lib/esm/feature/table/index.js +1 -0
  54. package/lib/esm/feature/table/index.js.map +1 -1
  55. package/lib/esm/feature/toolbar/index.js +490 -5
  56. package/lib/esm/feature/toolbar/index.js.map +1 -1
  57. package/lib/esm/feature/top-bar/index.js +1 -0
  58. package/lib/esm/feature/top-bar/index.js.map +1 -1
  59. package/lib/esm/index.js +1414 -15
  60. package/lib/esm/index.js.map +1 -1
  61. package/lib/esm/llm-providers/anthropic/index.js +145 -0
  62. package/lib/esm/llm-providers/anthropic/index.js.map +1 -0
  63. package/lib/esm/llm-providers/openai/index.js +136 -0
  64. package/lib/esm/llm-providers/openai/index.js.map +1 -0
  65. package/lib/theme/common/ai.css +446 -0
  66. package/lib/theme/common/code-mirror.css +14 -0
  67. package/lib/theme/common/diff.css +177 -0
  68. package/lib/theme/common/style.css +2 -0
  69. package/lib/tsconfig.tsbuildinfo +1 -1
  70. package/lib/types/feature/ai/ai.spec.d.ts +2 -0
  71. package/lib/types/feature/ai/ai.spec.d.ts.map +1 -0
  72. package/lib/types/feature/ai/commands.d.ts +24 -0
  73. package/lib/types/feature/ai/commands.d.ts.map +1 -0
  74. package/lib/types/feature/ai/context.d.ts +4 -0
  75. package/lib/types/feature/ai/context.d.ts.map +1 -0
  76. package/lib/types/feature/ai/diff-actions/index.d.ts +12 -0
  77. package/lib/types/feature/ai/diff-actions/index.d.ts.map +1 -0
  78. package/lib/types/feature/ai/diff-actions/view.d.ts +21 -0
  79. package/lib/types/feature/ai/diff-actions/view.d.ts.map +1 -0
  80. package/lib/types/feature/ai/index.d.ts +7 -0
  81. package/lib/types/feature/ai/index.d.ts.map +1 -0
  82. package/lib/types/feature/ai/instruction-tooltip/component.d.ts +26 -0
  83. package/lib/types/feature/ai/instruction-tooltip/component.d.ts.map +1 -0
  84. package/lib/types/feature/ai/instruction-tooltip/index.d.ts +17 -0
  85. package/lib/types/feature/ai/instruction-tooltip/index.d.ts.map +1 -0
  86. package/lib/types/feature/ai/instruction-tooltip/suggestions.d.ts +50 -0
  87. package/lib/types/feature/ai/instruction-tooltip/suggestions.d.ts.map +1 -0
  88. package/lib/types/feature/ai/instruction-tooltip/view.d.ts +19 -0
  89. package/lib/types/feature/ai/instruction-tooltip/view.d.ts.map +1 -0
  90. package/lib/types/feature/ai/streaming-indicator.d.ts +9 -0
  91. package/lib/types/feature/ai/streaming-indicator.d.ts.map +1 -0
  92. package/lib/types/feature/ai/types.d.ts +58 -0
  93. package/lib/types/feature/ai/types.d.ts.map +1 -0
  94. package/lib/types/feature/index.d.ts +4 -1
  95. package/lib/types/feature/index.d.ts.map +1 -1
  96. package/lib/types/feature/latex/inline-tooltip/inline-tooltip.spec.d.ts +2 -0
  97. package/lib/types/feature/latex/inline-tooltip/inline-tooltip.spec.d.ts.map +1 -0
  98. package/lib/types/feature/latex/inline-tooltip/view.d.ts.map +1 -1
  99. package/lib/types/feature/loader.d.ts.map +1 -1
  100. package/lib/types/feature/toolbar/config.d.ts.map +1 -1
  101. package/lib/types/feature/toolbar/index.d.ts +1 -0
  102. package/lib/types/feature/toolbar/index.d.ts.map +1 -1
  103. package/lib/types/icons/ai.d.ts +2 -0
  104. package/lib/types/icons/ai.d.ts.map +1 -0
  105. package/lib/types/icons/chevron-left.d.ts +2 -0
  106. package/lib/types/icons/chevron-left.d.ts.map +1 -0
  107. package/lib/types/icons/chevron-right.d.ts +2 -0
  108. package/lib/types/icons/chevron-right.d.ts.map +1 -0
  109. package/lib/types/icons/enter-key.d.ts +2 -0
  110. package/lib/types/icons/enter-key.d.ts.map +1 -0
  111. package/lib/types/icons/grammar-check.d.ts +2 -0
  112. package/lib/types/icons/grammar-check.d.ts.map +1 -0
  113. package/lib/types/icons/index.d.ts +11 -0
  114. package/lib/types/icons/index.d.ts.map +1 -1
  115. package/lib/types/icons/longer.d.ts +2 -0
  116. package/lib/types/icons/longer.d.ts.map +1 -0
  117. package/lib/types/icons/retry.d.ts +2 -0
  118. package/lib/types/icons/retry.d.ts.map +1 -0
  119. package/lib/types/icons/send-prompt.d.ts +2 -0
  120. package/lib/types/icons/send-prompt.d.ts.map +1 -0
  121. package/lib/types/icons/send.d.ts +2 -0
  122. package/lib/types/icons/send.d.ts.map +1 -0
  123. package/lib/types/icons/shorter.d.ts +2 -0
  124. package/lib/types/icons/shorter.d.ts.map +1 -0
  125. package/lib/types/icons/translate.d.ts +2 -0
  126. package/lib/types/icons/translate.d.ts.map +1 -0
  127. package/lib/types/llm-providers/anthropic/index.d.ts +21 -0
  128. package/lib/types/llm-providers/anthropic/index.d.ts.map +1 -0
  129. package/lib/types/llm-providers/openai/index.d.ts +15 -0
  130. package/lib/types/llm-providers/openai/index.d.ts.map +1 -0
  131. package/lib/types/llm-providers/providers.spec.d.ts +2 -0
  132. package/lib/types/llm-providers/providers.spec.d.ts.map +1 -0
  133. package/lib/types/llm-providers/shared.d.ts +16 -0
  134. package/lib/types/llm-providers/shared.d.ts.map +1 -0
  135. package/package.json +18 -2
  136. package/src/feature/ai/ai.spec.ts +742 -0
  137. package/src/feature/ai/commands.ts +257 -0
  138. package/src/feature/ai/context.ts +45 -0
  139. package/src/feature/ai/diff-actions/index.ts +95 -0
  140. package/src/feature/ai/diff-actions/view.ts +237 -0
  141. package/src/feature/ai/index.ts +118 -0
  142. package/src/feature/ai/instruction-tooltip/component.tsx +414 -0
  143. package/src/feature/ai/instruction-tooltip/index.ts +101 -0
  144. package/src/feature/ai/instruction-tooltip/suggestions.ts +249 -0
  145. package/src/feature/ai/instruction-tooltip/view.ts +159 -0
  146. package/src/feature/ai/streaming-indicator.ts +183 -0
  147. package/src/feature/ai/types.ts +178 -0
  148. package/src/feature/index.ts +8 -2
  149. package/src/feature/latex/inline-tooltip/inline-tooltip.spec.ts +81 -0
  150. package/src/feature/latex/inline-tooltip/view.ts +2 -0
  151. package/src/feature/loader.ts +4 -0
  152. package/src/feature/toolbar/config.ts +27 -1
  153. package/src/feature/toolbar/index.ts +1 -0
  154. package/src/icons/ai.ts +14 -0
  155. package/src/icons/chevron-left.ts +15 -0
  156. package/src/icons/chevron-right.ts +15 -0
  157. package/src/icons/enter-key.ts +13 -0
  158. package/src/icons/grammar-check.ts +13 -0
  159. package/src/icons/index.ts +11 -0
  160. package/src/icons/longer.ts +13 -0
  161. package/src/icons/retry.ts +13 -0
  162. package/src/icons/send-prompt.ts +13 -0
  163. package/src/icons/send.ts +13 -0
  164. package/src/icons/shorter.ts +13 -0
  165. package/src/icons/translate.ts +13 -0
  166. package/src/llm-providers/anthropic/index.ts +132 -0
  167. package/src/llm-providers/openai/index.ts +109 -0
  168. package/src/llm-providers/providers.spec.ts +472 -0
  169. package/src/llm-providers/shared.ts +160 -0
  170. package/src/theme/common/ai.css +430 -0
  171. package/src/theme/common/code-mirror.css +14 -0
  172. package/src/theme/common/diff.css +196 -0
  173. package/src/theme/common/style.css +2 -0
@@ -0,0 +1,742 @@
1
+ import type { EditorView } from '@milkdown/kit/prose/view'
2
+
3
+ import { commandsCtx, editorViewCtx } from '@milkdown/kit/core'
4
+ import {
5
+ clearDiffReviewCmd,
6
+ diffPluginKey,
7
+ startDiffReviewCmd,
8
+ } from '@milkdown/kit/plugin/diff'
9
+ import {
10
+ startStreamingCmd,
11
+ streamingPluginKey,
12
+ } from '@milkdown/kit/plugin/streaming'
13
+ import { TextSelection } from '@milkdown/kit/prose/state'
14
+ import { callCommand } from '@milkdown/kit/utils'
15
+ import { describe, expect, test, vi } from 'vitest'
16
+ import { nextTick } from 'vue'
17
+
18
+ import type { AIPromptContext } from './types'
19
+
20
+ import { Crepe } from '../../core'
21
+ import { CrepeFeature } from '../index'
22
+ import { aiSessionCtx, runAICmd } from './commands'
23
+ import { aiInstructionTooltipAPI } from './instruction-tooltip'
24
+
25
+ function waitForAsync() {
26
+ return new Promise<void>((resolve) => setTimeout(resolve, 0))
27
+ }
28
+
29
+ async function flushStream() {
30
+ // Streaming dispatches are scheduled across microtasks; a few ticks
31
+ // are enough for the simulated provider to drain.
32
+ for (let i = 0; i < 5; i++) await waitForAsync()
33
+ }
34
+
35
+ function dispatchKeyDown(
36
+ view: EditorView,
37
+ key: string,
38
+ modifiers: { metaKey?: boolean; ctrlKey?: boolean } = {}
39
+ ): boolean {
40
+ const event = new KeyboardEvent('keydown', {
41
+ key,
42
+ bubbles: true,
43
+ cancelable: true,
44
+ ...modifiers,
45
+ })
46
+ // Iterate every plugin's handleKeyDown until one claims the event.
47
+ // Returning `undefined` from the visitor keeps `someProp` looking;
48
+ // returning `true` short-circuits and is what we treat as "handled".
49
+ return (
50
+ view.someProp('handleKeyDown', (handler) =>
51
+ handler(view, event) ? true : undefined
52
+ ) === true
53
+ )
54
+ }
55
+
56
+ describe('AI streaming inline markdown', () => {
57
+ // Regression: replace-selection / insert-at-cursor flushes used to
58
+ // insert the buffer's first line as a plain text node, which left
59
+ // markdown syntax like **bold** and [link](url) visible in the
60
+ // document. The fix in plugin-streaming/flush.ts parses the first
61
+ // line as inline markdown so marks/links survive into the doc tree.
62
+ test('first line preserves strong and link marks across a paragraph break', async () => {
63
+ const crepe = new Crepe({
64
+ defaultValue: 'pre',
65
+ features: { [CrepeFeature.AI]: true },
66
+ featureConfigs: {
67
+ [CrepeFeature.AI]: {
68
+ provider: async function* () {
69
+ yield '**bold** and [a](https://example.com).\n\nSecond block.'
70
+ },
71
+ diffReviewOnEnd: false,
72
+ },
73
+ },
74
+ })
75
+ await crepe.create()
76
+ try {
77
+ crepe.editor.action(callCommand(runAICmd.key, { instruction: 'go' }))
78
+ await flushStream()
79
+
80
+ const view = crepe.editor.action((ctx) => ctx.get(editorViewCtx))
81
+ const docText = view.state.doc.textContent
82
+ expect(docText).not.toContain('**')
83
+ expect(docText).not.toContain('](')
84
+
85
+ let foundStrong = false
86
+ let foundLink = false
87
+ view.state.doc.descendants((node) => {
88
+ if (node.marks.some((m) => m.type.name === 'strong')) foundStrong = true
89
+ if (node.marks.some((m) => m.type.name === 'link')) foundLink = true
90
+ })
91
+ expect(foundStrong).toBe(true)
92
+ expect(foundLink).toBe(true)
93
+ } finally {
94
+ await crepe.destroy()
95
+ }
96
+ })
97
+
98
+ test('single-line response parses inline marks instead of raw text', async () => {
99
+ const crepe = new Crepe({
100
+ defaultValue: 'pre',
101
+ features: { [CrepeFeature.AI]: true },
102
+ featureConfigs: {
103
+ [CrepeFeature.AI]: {
104
+ provider: async function* () {
105
+ yield '**bold** and [a](https://example.com)'
106
+ },
107
+ diffReviewOnEnd: false,
108
+ },
109
+ },
110
+ })
111
+ await crepe.create()
112
+ try {
113
+ crepe.editor.action(callCommand(runAICmd.key, { instruction: 'go' }))
114
+ await flushStream()
115
+
116
+ const view = crepe.editor.action((ctx) => ctx.get(editorViewCtx))
117
+ const docText = view.state.doc.textContent
118
+ expect(docText).not.toContain('**')
119
+ expect(docText).not.toContain('](')
120
+ } finally {
121
+ await crepe.destroy()
122
+ }
123
+ })
124
+
125
+ test('block markers survive even when the line also has inline marks', async () => {
126
+ // `# **bold**` parses as `heading(strong('bold'))`. Extracting
127
+ // the heading's content would lose the `# ` and rewrite the
128
+ // streamed text. The mid-paragraph insert should keep the
129
+ // literal characters as plain text.
130
+ const crepe = new Crepe({
131
+ defaultValue: 'pre',
132
+ features: { [CrepeFeature.AI]: true },
133
+ featureConfigs: {
134
+ [CrepeFeature.AI]: {
135
+ provider: async function* () {
136
+ yield '# **bold**'
137
+ },
138
+ diffReviewOnEnd: false,
139
+ },
140
+ },
141
+ })
142
+ await crepe.create()
143
+ try {
144
+ crepe.editor.action(callCommand(runAICmd.key, { instruction: 'go' }))
145
+ await flushStream()
146
+
147
+ const view = crepe.editor.action((ctx) => ctx.get(editorViewCtx))
148
+ const docText = view.state.doc.textContent
149
+ expect(docText).toContain('# **bold**')
150
+ } finally {
151
+ await crepe.destroy()
152
+ }
153
+ })
154
+
155
+ test('leading whitespace before inline marks is preserved', async () => {
156
+ // CommonMark would strip the leading space when wrapping the line
157
+ // in a paragraph; without explicit whitespace preservation the
158
+ // streamed token would collide with the preceding character once
159
+ // inserted mid-paragraph.
160
+ const crepe = new Crepe({
161
+ defaultValue: 'pre',
162
+ features: { [CrepeFeature.AI]: true },
163
+ featureConfigs: {
164
+ [CrepeFeature.AI]: {
165
+ provider: async function* () {
166
+ yield ' **bold**'
167
+ },
168
+ diffReviewOnEnd: false,
169
+ },
170
+ },
171
+ })
172
+ await crepe.create()
173
+ try {
174
+ crepe.editor.action(callCommand(runAICmd.key, { instruction: 'go' }))
175
+ await flushStream()
176
+
177
+ const view = crepe.editor.action((ctx) => ctx.get(editorViewCtx))
178
+ const docText = view.state.doc.textContent
179
+ expect(docText).toContain(' bold')
180
+ expect(docText).not.toContain('prebold')
181
+
182
+ let foundStrong = false
183
+ view.state.doc.descendants((node) => {
184
+ if (node.marks.some((m) => m.type.name === 'strong')) foundStrong = true
185
+ })
186
+ expect(foundStrong).toBe(true)
187
+ } finally {
188
+ await crepe.destroy()
189
+ }
190
+ })
191
+
192
+ // Regression: when the AI replaces the *whole inline content* of a
193
+ // paragraph at non-zero depth with multi-block content, the slice
194
+ // has openEnd:0 but `to` is at depth 1. ProseMirror's reconciliation
195
+ // of that depth mismatch used to drop the next sibling block.
196
+ // `applySplitBlock` now extends `to` past the parent close so the
197
+ // boundary matches the slice's openEnd. This test pins that
198
+ // behavior so future refactors don't reintroduce the drop.
199
+ test('multi-block replace covering whole paragraph keeps the next sibling', async () => {
200
+ const crepe = new Crepe({
201
+ defaultValue: 'first\n\nmiddle\n\nlast',
202
+ features: { [CrepeFeature.AI]: true },
203
+ featureConfigs: {
204
+ [CrepeFeature.AI]: {
205
+ provider: async function* () {
206
+ yield 'replaced inline.\n\nNew block.'
207
+ },
208
+ diffReviewOnEnd: false,
209
+ },
210
+ },
211
+ })
212
+ await crepe.create()
213
+ try {
214
+ // Programmatically select the entire inline content of the
215
+ // "middle" paragraph (avoids platform-specific Shift+End
216
+ // behavior that affected the e2e test).
217
+ crepe.editor.action((ctx) => {
218
+ const view = ctx.get(editorViewCtx)
219
+ const doc = view.state.doc
220
+ let from = -1
221
+ let to = -1
222
+ doc.descendants((node, pos) => {
223
+ if (node.type.name === 'paragraph' && node.textContent === 'middle') {
224
+ from = pos + 1
225
+ to = pos + 1 + node.content.size
226
+ return false
227
+ }
228
+ return true
229
+ })
230
+ view.dispatch(
231
+ view.state.tr.setSelection(TextSelection.create(doc, from, to))
232
+ )
233
+ })
234
+
235
+ crepe.editor.action(callCommand(runAICmd.key, { instruction: 'go' }))
236
+ await flushStream()
237
+
238
+ const view = crepe.editor.action((ctx) => ctx.get(editorViewCtx))
239
+ const docText = view.state.doc.textContent
240
+ expect(docText).toContain('first')
241
+ expect(docText).toContain('replaced inline.')
242
+ expect(docText).toContain('New block.')
243
+ expect(docText).toContain('last')
244
+ expect(docText).not.toContain('middle')
245
+ } finally {
246
+ await crepe.destroy()
247
+ }
248
+ })
249
+ })
250
+
251
+ describe('AI onError', () => {
252
+ test('provider error triggers onError with aiProviderError code', async () => {
253
+ const onError = vi.fn()
254
+
255
+ const crepe = new Crepe({
256
+ features: {
257
+ [CrepeFeature.AI]: true,
258
+ },
259
+ featureConfigs: {
260
+ [CrepeFeature.AI]: {
261
+ provider: async function* () {
262
+ yield 'partial'
263
+ throw new Error('network failure')
264
+ },
265
+ onError,
266
+ diffReviewOnEnd: false,
267
+ },
268
+ },
269
+ })
270
+ await crepe.create()
271
+ try {
272
+ crepe.editor.action(callCommand(runAICmd.key, { instruction: 'test' }))
273
+ await waitForAsync()
274
+
275
+ expect(onError).toHaveBeenCalledOnce()
276
+ const error = onError.mock.calls[0]![0]
277
+ expect(error.code).toBe('aiProviderError')
278
+ expect(error.message).toContain('network failure')
279
+ expect(error.cause).toBeInstanceOf(Error)
280
+ } finally {
281
+ await crepe.destroy()
282
+ }
283
+ })
284
+
285
+ test('buildContext error triggers onError with aiBuildContextError code', async () => {
286
+ const onError = vi.fn()
287
+
288
+ const crepe = new Crepe({
289
+ features: {
290
+ [CrepeFeature.AI]: true,
291
+ },
292
+ featureConfigs: {
293
+ [CrepeFeature.AI]: {
294
+ provider: async function* () {
295
+ yield 'hello'
296
+ },
297
+ buildContext: () => {
298
+ throw new Error('context build failed')
299
+ },
300
+ onError,
301
+ diffReviewOnEnd: false,
302
+ },
303
+ },
304
+ })
305
+ await crepe.create()
306
+ try {
307
+ crepe.editor.action(callCommand(runAICmd.key, { instruction: 'test' }))
308
+ await waitForAsync()
309
+
310
+ expect(onError).toHaveBeenCalledOnce()
311
+ const error = onError.mock.calls[0]![0]
312
+ expect(error.code).toBe('aiBuildContextError')
313
+ expect(error.message).toContain('context build failed')
314
+ expect(error.cause).toBeInstanceOf(Error)
315
+ } finally {
316
+ await crepe.destroy()
317
+ }
318
+ })
319
+ })
320
+
321
+ describe('AI prompt context selection', () => {
322
+ // Regression: inline-only selections (e.g. selecting "bold" inside a
323
+ // single paragraph) used to fall through to `doc.textBetween` because
324
+ // `topNodeType.createAndFill` rejects inline content. That stripped
325
+ // marks before the selection ever reached the provider, so a single-
326
+ // paragraph translate would lose bold/italic/link formatting.
327
+ test('inline-only selection preserves marks in promptContext.selection', async () => {
328
+ const provider = vi.fn(async function* () {
329
+ yield 'unused'
330
+ })
331
+ const crepe = new Crepe({
332
+ defaultValue: '**bold** and *italic* text',
333
+ features: { [CrepeFeature.AI]: true },
334
+ featureConfigs: {
335
+ [CrepeFeature.AI]: { provider, diffReviewOnEnd: false },
336
+ },
337
+ })
338
+ await crepe.create()
339
+ try {
340
+ crepe.editor.action((ctx) => {
341
+ const view = ctx.get(editorViewCtx)
342
+ const doc = view.state.doc
343
+ let from = -1
344
+ let to = -1
345
+ doc.descendants((node, pos) => {
346
+ if (node.type.name === 'paragraph') {
347
+ from = pos + 1
348
+ to = pos + 1 + node.content.size
349
+ return false
350
+ }
351
+ return true
352
+ })
353
+ view.dispatch(
354
+ view.state.tr.setSelection(TextSelection.create(doc, from, to))
355
+ )
356
+ })
357
+
358
+ crepe.editor.action(callCommand(runAICmd.key, { instruction: 'go' }))
359
+ await waitForAsync()
360
+
361
+ expect(provider).toHaveBeenCalled()
362
+ const [promptContext] = provider.mock.calls[0] as unknown as [
363
+ AIPromptContext,
364
+ ]
365
+ expect(promptContext.selection).toContain('**bold**')
366
+ expect(promptContext.selection).toContain('*italic*')
367
+ } finally {
368
+ await crepe.destroy()
369
+ }
370
+ })
371
+ })
372
+
373
+ describe('AI session retry metadata', () => {
374
+ async function makeCrepe() {
375
+ const crepe = new Crepe({
376
+ defaultValue: 'hello world',
377
+ features: { [CrepeFeature.AI]: true },
378
+ featureConfigs: {
379
+ [CrepeFeature.AI]: {
380
+ provider: async function* () {
381
+ yield 'replacement'
382
+ },
383
+ },
384
+ },
385
+ })
386
+ await crepe.create()
387
+ return crepe
388
+ }
389
+
390
+ test('runAICmd persists instruction, label, and selection range', async () => {
391
+ const crepe = await makeCrepe()
392
+ try {
393
+ crepe.editor.action(
394
+ callCommand(runAICmd.key, {
395
+ instruction: 'Improve writing',
396
+ label: 'Improving writing',
397
+ })
398
+ )
399
+ await flushStream()
400
+
401
+ const session = crepe.editor.action((ctx) => ctx.get(aiSessionCtx.key))
402
+ expect(session.lastInstruction).toBe('Improve writing')
403
+ expect(session.lastLabel).toBe('Improving writing')
404
+ expect(session.lastFrom).toBeGreaterThanOrEqual(0)
405
+ expect(session.lastTo).toBeGreaterThanOrEqual(session.lastFrom)
406
+ } finally {
407
+ await crepe.destroy()
408
+ }
409
+ })
410
+
411
+ test('rejects a second runAICmd while a session is in flight', async () => {
412
+ const crepe = new Crepe({
413
+ defaultValue: 'hello',
414
+ features: { [CrepeFeature.AI]: true },
415
+ featureConfigs: {
416
+ [CrepeFeature.AI]: {
417
+ provider: async function* () {
418
+ // Hang forever — caller should see runAICmd return false.
419
+ await new Promise(() => {})
420
+ yield ''
421
+ },
422
+ },
423
+ },
424
+ })
425
+ await crepe.create()
426
+ try {
427
+ const first = crepe.editor.action(
428
+ callCommand(runAICmd.key, { instruction: 'first' })
429
+ )
430
+ const second = crepe.editor.action(
431
+ callCommand(runAICmd.key, { instruction: 'second' })
432
+ )
433
+
434
+ expect(first).toBe(true)
435
+ expect(second).toBe(false)
436
+ } finally {
437
+ await crepe.destroy()
438
+ }
439
+ })
440
+
441
+ test('lastInstruction survives session cleanup so Retry can replay it', async () => {
442
+ const crepe = await makeCrepe()
443
+ try {
444
+ crepe.editor.action(
445
+ callCommand(runAICmd.key, { instruction: 'Improve writing' })
446
+ )
447
+ await flushStream()
448
+
449
+ // Whether or not diff review activates, the persistent retry fields
450
+ // must outlive the live session state.
451
+ crepe.editor.action((ctx) => {
452
+ ctx.get(commandsCtx).call(clearDiffReviewCmd.key)
453
+ })
454
+
455
+ const session = crepe.editor.action((ctx) => ctx.get(aiSessionCtx.key))
456
+ expect(session.abortController).toBeNull()
457
+ expect(session.label).toBe('')
458
+ expect(session.lastInstruction).toBe('Improve writing')
459
+ } finally {
460
+ await crepe.destroy()
461
+ }
462
+ })
463
+ })
464
+
465
+ describe('AI keybindings', () => {
466
+ test('Mod-Enter accepts all changes for an AI-owned diff review', async () => {
467
+ const crepe = new Crepe({
468
+ defaultValue: 'hello',
469
+ features: { [CrepeFeature.AI]: true },
470
+ featureConfigs: {
471
+ [CrepeFeature.AI]: {
472
+ provider: async function* () {
473
+ yield 'unused'
474
+ },
475
+ },
476
+ },
477
+ })
478
+ await crepe.create()
479
+ try {
480
+ // Simulate the end of an AI session: the streaming side flips
481
+ // `diffOwnedByAI` on right before handing off to diff review.
482
+ crepe.editor.action((ctx) => {
483
+ const session = ctx.get(aiSessionCtx.key)
484
+ ctx.set(aiSessionCtx.key, { ...session, diffOwnedByAI: true })
485
+ ctx.get(commandsCtx).call(startDiffReviewCmd.key, 'goodbye')
486
+ })
487
+
488
+ const view = crepe.editor.ctx.get(editorViewCtx)
489
+ expect(diffPluginKey.getState(view.state)?.active).toBe(true)
490
+
491
+ expect(dispatchKeyDown(view, 'Enter', { metaKey: true })).toBe(true)
492
+ expect(diffPluginKey.getState(view.state)?.active).toBeFalsy()
493
+ expect(view.state.doc.textContent).toContain('goodbye')
494
+ } finally {
495
+ await crepe.destroy()
496
+ }
497
+ })
498
+
499
+ test('Mod-Enter does not hijack a manually-started diff review', async () => {
500
+ const crepe = new Crepe({
501
+ defaultValue: 'hello',
502
+ features: { [CrepeFeature.AI]: true },
503
+ featureConfigs: {
504
+ [CrepeFeature.AI]: {
505
+ provider: async function* () {
506
+ yield 'unused'
507
+ },
508
+ },
509
+ },
510
+ })
511
+ await crepe.create()
512
+ try {
513
+ // No `diffOwnedByAI` flip — this represents host code calling
514
+ // `startDiffReviewCmd` directly, independent of the AI feature.
515
+ crepe.editor.action((ctx) => {
516
+ ctx.get(commandsCtx).call(startDiffReviewCmd.key, 'goodbye')
517
+ })
518
+
519
+ const view = crepe.editor.ctx.get(editorViewCtx)
520
+ expect(diffPluginKey.getState(view.state)?.active).toBe(true)
521
+
522
+ // Handler must let the event fall through; the diff stays active.
523
+ expect(dispatchKeyDown(view, 'Enter', { metaKey: true })).toBe(false)
524
+ expect(diffPluginKey.getState(view.state)?.active).toBe(true)
525
+ } finally {
526
+ await crepe.destroy()
527
+ }
528
+ })
529
+
530
+ test('Esc aborts an in-flight AI session', async () => {
531
+ const crepe = new Crepe({
532
+ features: { [CrepeFeature.AI]: true },
533
+ featureConfigs: {
534
+ [CrepeFeature.AI]: {
535
+ provider: async function* () {
536
+ // Hang indefinitely so the session stays open until aborted.
537
+ await new Promise(() => {})
538
+ yield ''
539
+ },
540
+ diffReviewOnEnd: false,
541
+ },
542
+ },
543
+ })
544
+ await crepe.create()
545
+ try {
546
+ crepe.editor.action(callCommand(runAICmd.key, { instruction: 'test' }))
547
+ await waitForAsync()
548
+
549
+ const view = crepe.editor.ctx.get(editorViewCtx)
550
+ expect(streamingPluginKey.getState(view.state)?.active).toBe(true)
551
+
552
+ expect(dispatchKeyDown(view, 'Escape')).toBe(true)
553
+ await waitForAsync()
554
+
555
+ const session = crepe.editor.action((ctx) => ctx.get(aiSessionCtx.key))
556
+ expect(session.abortController).toBeNull()
557
+ expect(streamingPluginKey.getState(view.state)?.active).toBeFalsy()
558
+ } finally {
559
+ await crepe.destroy()
560
+ }
561
+ })
562
+
563
+ test('Esc aborts a manual streaming session when no AI session is active', async () => {
564
+ const crepe = new Crepe({
565
+ features: { [CrepeFeature.AI]: true },
566
+ })
567
+ await crepe.create()
568
+ try {
569
+ crepe.editor.action((ctx) => {
570
+ ctx.get(commandsCtx).call(startStreamingCmd.key, { insertAt: 'cursor' })
571
+ })
572
+
573
+ const view = crepe.editor.ctx.get(editorViewCtx)
574
+ expect(streamingPluginKey.getState(view.state)?.active).toBe(true)
575
+ // Sanity: no AI session is in flight.
576
+ expect(
577
+ crepe.editor.action((ctx) => ctx.get(aiSessionCtx.key).abortController)
578
+ ).toBeNull()
579
+
580
+ expect(dispatchKeyDown(view, 'Escape')).toBe(true)
581
+ expect(streamingPluginKey.getState(view.state)?.active).toBeFalsy()
582
+ } finally {
583
+ await crepe.destroy()
584
+ }
585
+ })
586
+ })
587
+
588
+ describe('AI diff actions panel visibility', () => {
589
+ /// Panels from earlier tests may linger in `document.body` until their
590
+ /// editor view is fully torn down, so always look up the panel
591
+ /// relative to the current crepe instance's `.milkdown` host.
592
+ function findPanelFor(crepe: Crepe): HTMLElement | null {
593
+ const view = crepe.editor.ctx.get(editorViewCtx)
594
+ const host = view.dom.closest('.milkdown') ?? document.body
595
+ return host.querySelector<HTMLElement>('.milkdown-ai-diff-actions')
596
+ }
597
+
598
+ test('panel stays hidden for a manually-started diff review', async () => {
599
+ const crepe = new Crepe({
600
+ defaultValue: 'hello',
601
+ features: { [CrepeFeature.AI]: true },
602
+ featureConfigs: {
603
+ [CrepeFeature.AI]: {
604
+ provider: async function* () {
605
+ yield 'unused'
606
+ },
607
+ },
608
+ },
609
+ })
610
+ await crepe.create()
611
+ try {
612
+ crepe.editor.action((ctx) => {
613
+ ctx.get(commandsCtx).call(startDiffReviewCmd.key, 'goodbye')
614
+ })
615
+
616
+ expect(findPanelFor(crepe)?.dataset.show).toBe('false')
617
+ } finally {
618
+ await crepe.destroy()
619
+ }
620
+ })
621
+
622
+ test('panel shows for an AI-owned diff review', async () => {
623
+ const crepe = new Crepe({
624
+ defaultValue: 'hello',
625
+ features: { [CrepeFeature.AI]: true },
626
+ featureConfigs: {
627
+ [CrepeFeature.AI]: {
628
+ provider: async function* () {
629
+ yield 'unused'
630
+ },
631
+ },
632
+ },
633
+ })
634
+ await crepe.create()
635
+ try {
636
+ crepe.editor.action((ctx) => {
637
+ const session = ctx.get(aiSessionCtx.key)
638
+ ctx.set(aiSessionCtx.key, { ...session, diffOwnedByAI: true })
639
+ ctx.get(commandsCtx).call(startDiffReviewCmd.key, 'goodbye')
640
+ })
641
+
642
+ expect(findPanelFor(crepe)?.dataset.show).toBe('true')
643
+ } finally {
644
+ await crepe.destroy()
645
+ }
646
+ })
647
+
648
+ test('Retry button clears the diff review and re-runs the stored prompt', async () => {
649
+ // Hanging provider so the re-issued AI session stays in streaming
650
+ // and doesn't transition back into diff review while we assert.
651
+ const provider = vi.fn(async function* () {
652
+ await new Promise(() => {})
653
+ yield ''
654
+ })
655
+ const crepe = new Crepe({
656
+ defaultValue: 'hello world',
657
+ features: { [CrepeFeature.AI]: true },
658
+ featureConfigs: { [CrepeFeature.AI]: { provider } },
659
+ })
660
+ await crepe.create()
661
+ try {
662
+ // Stage the editor as if a previous AI run had ended in diff
663
+ // review: persistent retry metadata + AI ownership flag + active
664
+ // diff. We bypass `runAICmd` so the assertions don't have to wait
665
+ // for a full streaming round-trip.
666
+ crepe.editor.action((ctx) => {
667
+ const session = ctx.get(aiSessionCtx.key)
668
+ ctx.set(aiSessionCtx.key, {
669
+ ...session,
670
+ lastInstruction: 'Improve writing',
671
+ lastLabel: 'Improving writing',
672
+ lastFrom: 0,
673
+ lastTo: 5,
674
+ diffOwnedByAI: true,
675
+ })
676
+ ctx.get(commandsCtx).call(startDiffReviewCmd.key, 'goodbye')
677
+ })
678
+
679
+ const view = crepe.editor.ctx.get(editorViewCtx)
680
+ expect(diffPluginKey.getState(view.state)?.active).toBe(true)
681
+
682
+ const panel = findPanelFor(crepe)
683
+ const retryBtn = panel?.querySelector<HTMLButtonElement>(
684
+ '.milkdown-ai-diff-actions-btn-retry'
685
+ )
686
+ expect(retryBtn).not.toBeNull()
687
+ expect(retryBtn?.disabled).toBe(false)
688
+
689
+ retryBtn?.click()
690
+ // The click handler runs synchronously; the provider call happens
691
+ // on a microtask once streaming has been set up.
692
+ await waitForAsync()
693
+
694
+ // Diff review was cleared and a fresh streaming session is now in
695
+ // flight (the hanging provider keeps it there for the rest of the
696
+ // test).
697
+ expect(diffPluginKey.getState(view.state)?.active).toBeFalsy()
698
+ expect(streamingPluginKey.getState(view.state)?.active).toBe(true)
699
+ // Provider received the persisted instruction.
700
+ expect(provider).toHaveBeenCalled()
701
+ const [promptContext] = provider.mock.calls[0] as unknown as [
702
+ AIPromptContext,
703
+ ]
704
+ expect(promptContext.instruction).toBe('Improve writing')
705
+ } finally {
706
+ await crepe.destroy()
707
+ }
708
+ })
709
+ })
710
+
711
+ describe('AI instruction palette', () => {
712
+ test('show() mounts a palette input under .milkdown-ai-instruction', async () => {
713
+ const crepe = new Crepe({
714
+ defaultValue: 'hello',
715
+ features: { [CrepeFeature.AI]: true },
716
+ featureConfigs: {
717
+ [CrepeFeature.AI]: {
718
+ provider: async function* () {
719
+ yield 'unused'
720
+ },
721
+ },
722
+ },
723
+ })
724
+ await crepe.create()
725
+ try {
726
+ crepe.editor.ctx.get(aiInstructionTooltipAPI.key).show(0, 0)
727
+ await nextTick()
728
+
729
+ // Scope the lookup to this editor's `.milkdown` host so a stale
730
+ // palette left in `document.body` by an earlier test can't make
731
+ // the assertion accidentally pass.
732
+ const view = crepe.editor.ctx.get(editorViewCtx)
733
+ const host = view.dom.closest('.milkdown') ?? document.body
734
+ const input = host.querySelector<HTMLInputElement>(
735
+ '.milkdown-ai-instruction input'
736
+ )
737
+ expect(input).not.toBeNull()
738
+ } finally {
739
+ await crepe.destroy()
740
+ }
741
+ })
742
+ })