@milkdown/crepe 7.19.2 → 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 (209) hide show
  1. package/lib/cjs/builder.js +44 -1
  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 +9 -2
  6. package/lib/cjs/feature/block-edit/index.js.map +1 -1
  7. package/lib/cjs/feature/code-mirror/index.js +2 -0
  8. package/lib/cjs/feature/code-mirror/index.js.map +1 -1
  9. package/lib/cjs/feature/cursor/index.js +2 -0
  10. package/lib/cjs/feature/cursor/index.js.map +1 -1
  11. package/lib/cjs/feature/image-block/index.js +5 -1
  12. package/lib/cjs/feature/image-block/index.js.map +1 -1
  13. package/lib/cjs/feature/latex/index.js +7 -0
  14. package/lib/cjs/feature/latex/index.js.map +1 -1
  15. package/lib/cjs/feature/link-tooltip/index.js +2 -0
  16. package/lib/cjs/feature/link-tooltip/index.js.map +1 -1
  17. package/lib/cjs/feature/list-item/index.js +2 -0
  18. package/lib/cjs/feature/list-item/index.js.map +1 -1
  19. package/lib/cjs/feature/placeholder/index.js +2 -0
  20. package/lib/cjs/feature/placeholder/index.js.map +1 -1
  21. package/lib/cjs/feature/table/index.js +2 -0
  22. package/lib/cjs/feature/table/index.js.map +1 -1
  23. package/lib/cjs/feature/toolbar/index.js +497 -5
  24. package/lib/cjs/feature/toolbar/index.js.map +1 -1
  25. package/lib/cjs/feature/top-bar/index.js +791 -0
  26. package/lib/cjs/feature/top-bar/index.js.map +1 -0
  27. package/lib/cjs/index.js +2047 -160
  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 +44 -1
  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 +9 -2
  38. package/lib/esm/feature/block-edit/index.js.map +1 -1
  39. package/lib/esm/feature/code-mirror/index.js +2 -0
  40. package/lib/esm/feature/code-mirror/index.js.map +1 -1
  41. package/lib/esm/feature/cursor/index.js +2 -0
  42. package/lib/esm/feature/cursor/index.js.map +1 -1
  43. package/lib/esm/feature/image-block/index.js +5 -1
  44. package/lib/esm/feature/image-block/index.js.map +1 -1
  45. package/lib/esm/feature/latex/index.js +7 -0
  46. package/lib/esm/feature/latex/index.js.map +1 -1
  47. package/lib/esm/feature/link-tooltip/index.js +2 -0
  48. package/lib/esm/feature/link-tooltip/index.js.map +1 -1
  49. package/lib/esm/feature/list-item/index.js +2 -0
  50. package/lib/esm/feature/list-item/index.js.map +1 -1
  51. package/lib/esm/feature/placeholder/index.js +2 -0
  52. package/lib/esm/feature/placeholder/index.js.map +1 -1
  53. package/lib/esm/feature/table/index.js +2 -0
  54. package/lib/esm/feature/table/index.js.map +1 -1
  55. package/lib/esm/feature/toolbar/index.js +499 -7
  56. package/lib/esm/feature/toolbar/index.js.map +1 -1
  57. package/lib/esm/feature/top-bar/index.js +789 -0
  58. package/lib/esm/feature/top-bar/index.js.map +1 -0
  59. package/lib/esm/index.js +2040 -153
  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 +3 -0
  69. package/lib/theme/common/top-bar.css +152 -0
  70. package/lib/tsconfig.tsbuildinfo +1 -1
  71. package/lib/types/core/builder.d.ts +2 -1
  72. package/lib/types/core/builder.d.ts.map +1 -1
  73. package/lib/types/feature/ai/ai.spec.d.ts +2 -0
  74. package/lib/types/feature/ai/ai.spec.d.ts.map +1 -0
  75. package/lib/types/feature/ai/commands.d.ts +24 -0
  76. package/lib/types/feature/ai/commands.d.ts.map +1 -0
  77. package/lib/types/feature/ai/context.d.ts +4 -0
  78. package/lib/types/feature/ai/context.d.ts.map +1 -0
  79. package/lib/types/feature/ai/diff-actions/index.d.ts +12 -0
  80. package/lib/types/feature/ai/diff-actions/index.d.ts.map +1 -0
  81. package/lib/types/feature/ai/diff-actions/view.d.ts +21 -0
  82. package/lib/types/feature/ai/diff-actions/view.d.ts.map +1 -0
  83. package/lib/types/feature/ai/index.d.ts +7 -0
  84. package/lib/types/feature/ai/index.d.ts.map +1 -0
  85. package/lib/types/feature/ai/instruction-tooltip/component.d.ts +26 -0
  86. package/lib/types/feature/ai/instruction-tooltip/component.d.ts.map +1 -0
  87. package/lib/types/feature/ai/instruction-tooltip/index.d.ts +17 -0
  88. package/lib/types/feature/ai/instruction-tooltip/index.d.ts.map +1 -0
  89. package/lib/types/feature/ai/instruction-tooltip/suggestions.d.ts +50 -0
  90. package/lib/types/feature/ai/instruction-tooltip/suggestions.d.ts.map +1 -0
  91. package/lib/types/feature/ai/instruction-tooltip/view.d.ts +19 -0
  92. package/lib/types/feature/ai/instruction-tooltip/view.d.ts.map +1 -0
  93. package/lib/types/feature/ai/streaming-indicator.d.ts +9 -0
  94. package/lib/types/feature/ai/streaming-indicator.d.ts.map +1 -0
  95. package/lib/types/feature/ai/types.d.ts +58 -0
  96. package/lib/types/feature/ai/types.d.ts.map +1 -0
  97. package/lib/types/feature/block-edit/handle/component.d.ts.map +1 -1
  98. package/lib/types/feature/block-edit/menu/component.d.ts.map +1 -1
  99. package/lib/types/feature/image-block/index.d.ts +2 -0
  100. package/lib/types/feature/image-block/index.d.ts.map +1 -1
  101. package/lib/types/feature/index.d.ts +7 -1
  102. package/lib/types/feature/index.d.ts.map +1 -1
  103. package/lib/types/feature/latex/inline-tooltip/component.d.ts.map +1 -1
  104. package/lib/types/feature/latex/inline-tooltip/inline-tooltip.spec.d.ts +2 -0
  105. package/lib/types/feature/latex/inline-tooltip/inline-tooltip.spec.d.ts.map +1 -0
  106. package/lib/types/feature/latex/inline-tooltip/view.d.ts.map +1 -1
  107. package/lib/types/feature/loader.d.ts.map +1 -1
  108. package/lib/types/feature/toolbar/component.d.ts.map +1 -1
  109. package/lib/types/feature/toolbar/config.d.ts +1 -1
  110. package/lib/types/feature/toolbar/config.d.ts.map +1 -1
  111. package/lib/types/feature/toolbar/index.d.ts +1 -0
  112. package/lib/types/feature/toolbar/index.d.ts.map +1 -1
  113. package/lib/types/feature/top-bar/component.d.ts +11 -0
  114. package/lib/types/feature/top-bar/component.d.ts.map +1 -0
  115. package/lib/types/feature/top-bar/config.d.ts +34 -0
  116. package/lib/types/feature/top-bar/config.d.ts.map +1 -0
  117. package/lib/types/feature/top-bar/index.d.ts +26 -0
  118. package/lib/types/feature/top-bar/index.d.ts.map +1 -0
  119. package/lib/types/icons/ai.d.ts +2 -0
  120. package/lib/types/icons/ai.d.ts.map +1 -0
  121. package/lib/types/icons/chevron-left.d.ts +2 -0
  122. package/lib/types/icons/chevron-left.d.ts.map +1 -0
  123. package/lib/types/icons/chevron-right.d.ts +2 -0
  124. package/lib/types/icons/chevron-right.d.ts.map +1 -0
  125. package/lib/types/icons/code-block.d.ts +2 -0
  126. package/lib/types/icons/code-block.d.ts.map +1 -0
  127. package/lib/types/icons/enter-key.d.ts +2 -0
  128. package/lib/types/icons/enter-key.d.ts.map +1 -0
  129. package/lib/types/icons/grammar-check.d.ts +2 -0
  130. package/lib/types/icons/grammar-check.d.ts.map +1 -0
  131. package/lib/types/icons/index.d.ts +12 -0
  132. package/lib/types/icons/index.d.ts.map +1 -1
  133. package/lib/types/icons/longer.d.ts +2 -0
  134. package/lib/types/icons/longer.d.ts.map +1 -0
  135. package/lib/types/icons/retry.d.ts +2 -0
  136. package/lib/types/icons/retry.d.ts.map +1 -0
  137. package/lib/types/icons/send-prompt.d.ts +2 -0
  138. package/lib/types/icons/send-prompt.d.ts.map +1 -0
  139. package/lib/types/icons/send.d.ts +2 -0
  140. package/lib/types/icons/send.d.ts.map +1 -0
  141. package/lib/types/icons/shorter.d.ts +2 -0
  142. package/lib/types/icons/shorter.d.ts.map +1 -0
  143. package/lib/types/icons/translate.d.ts +2 -0
  144. package/lib/types/icons/translate.d.ts.map +1 -0
  145. package/lib/types/llm-providers/anthropic/index.d.ts +21 -0
  146. package/lib/types/llm-providers/anthropic/index.d.ts.map +1 -0
  147. package/lib/types/llm-providers/openai/index.d.ts +15 -0
  148. package/lib/types/llm-providers/openai/index.d.ts.map +1 -0
  149. package/lib/types/llm-providers/providers.spec.d.ts +2 -0
  150. package/lib/types/llm-providers/providers.spec.d.ts.map +1 -0
  151. package/lib/types/llm-providers/shared.d.ts +16 -0
  152. package/lib/types/llm-providers/shared.d.ts.map +1 -0
  153. package/lib/types/utils/group-builder.d.ts +1 -1
  154. package/lib/types/utils/group-builder.d.ts.map +1 -1
  155. package/lib/types/utils/keep-alive.d.ts +2 -0
  156. package/lib/types/utils/keep-alive.d.ts.map +1 -0
  157. package/package.json +34 -13
  158. package/src/core/builder.ts +39 -2
  159. package/src/feature/ai/ai.spec.ts +742 -0
  160. package/src/feature/ai/commands.ts +257 -0
  161. package/src/feature/ai/context.ts +45 -0
  162. package/src/feature/ai/diff-actions/index.ts +95 -0
  163. package/src/feature/ai/diff-actions/view.ts +237 -0
  164. package/src/feature/ai/index.ts +118 -0
  165. package/src/feature/ai/instruction-tooltip/component.tsx +414 -0
  166. package/src/feature/ai/instruction-tooltip/index.ts +101 -0
  167. package/src/feature/ai/instruction-tooltip/suggestions.ts +249 -0
  168. package/src/feature/ai/instruction-tooltip/view.ts +159 -0
  169. package/src/feature/ai/streaming-indicator.ts +183 -0
  170. package/src/feature/ai/types.ts +178 -0
  171. package/src/feature/block-edit/handle/component.tsx +3 -2
  172. package/src/feature/block-edit/menu/component.tsx +3 -2
  173. package/src/feature/block-edit/menu/config.ts +1 -1
  174. package/src/feature/image-block/index.ts +4 -0
  175. package/src/feature/index.ts +14 -2
  176. package/src/feature/latex/inline-tooltip/component.tsx +4 -2
  177. package/src/feature/latex/inline-tooltip/inline-tooltip.spec.ts +81 -0
  178. package/src/feature/latex/inline-tooltip/view.ts +2 -0
  179. package/src/feature/loader.ts +8 -0
  180. package/src/feature/toolbar/component.tsx +7 -5
  181. package/src/feature/toolbar/config.ts +27 -1
  182. package/src/feature/toolbar/index.ts +1 -0
  183. package/src/feature/top-bar/component.tsx +198 -0
  184. package/src/feature/top-bar/config.ts +367 -0
  185. package/src/feature/top-bar/index.ts +113 -0
  186. package/src/icons/ai.ts +14 -0
  187. package/src/icons/chevron-left.ts +15 -0
  188. package/src/icons/chevron-right.ts +15 -0
  189. package/src/icons/code-block.ts +12 -0
  190. package/src/icons/enter-key.ts +13 -0
  191. package/src/icons/grammar-check.ts +13 -0
  192. package/src/icons/index.ts +12 -0
  193. package/src/icons/longer.ts +13 -0
  194. package/src/icons/retry.ts +13 -0
  195. package/src/icons/send-prompt.ts +13 -0
  196. package/src/icons/send.ts +13 -0
  197. package/src/icons/shorter.ts +13 -0
  198. package/src/icons/translate.ts +13 -0
  199. package/src/llm-providers/anthropic/index.ts +132 -0
  200. package/src/llm-providers/openai/index.ts +109 -0
  201. package/src/llm-providers/providers.spec.ts +472 -0
  202. package/src/llm-providers/shared.ts +160 -0
  203. package/src/theme/common/ai.css +430 -0
  204. package/src/theme/common/code-mirror.css +14 -0
  205. package/src/theme/common/diff.css +196 -0
  206. package/src/theme/common/style.css +3 -0
  207. package/src/theme/common/top-bar.css +156 -0
  208. package/src/utils/group-builder.ts +1 -1
  209. package/src/utils/keep-alive.ts +3 -0
@@ -0,0 +1,257 @@
1
+ import type { Ctx } from '@milkdown/kit/ctx'
2
+ import type { MilkdownError } from '@milkdown/kit/exception'
3
+
4
+ import { commandsCtx } from '@milkdown/kit/core'
5
+ import { aiBuildContextError, aiProviderError } from '@milkdown/kit/exception'
6
+ import { diffPluginKey } from '@milkdown/kit/plugin/diff'
7
+ import {
8
+ abortStreamingCmd,
9
+ endStreamingCmd,
10
+ pushChunkCmd,
11
+ startStreamingCmd,
12
+ streamingPluginKey,
13
+ } from '@milkdown/kit/plugin/streaming'
14
+ import { $command, $ctx } from '@milkdown/kit/utils'
15
+
16
+ import type { AIProvider, AIPromptContext, RunAIOptions } from './types'
17
+
18
+ import { defaultBuildContext } from './context'
19
+
20
+ // ---------------------------------------------------------------------------
21
+ // Context slices
22
+ // ---------------------------------------------------------------------------
23
+
24
+ /// Holds the user-supplied provider and prompt builder. Populated by
25
+ /// the AI feature's setup function from `AIFeatureConfig`. `aiIcon`
26
+ /// lives here too so that other features (notably the toolbar) can pick
27
+ /// up the AI feature's icon override at render time.
28
+ export const aiProviderConfig = $ctx(
29
+ {
30
+ provider: undefined as AIProvider | undefined,
31
+ buildContext: undefined as
32
+ | ((ctx: Ctx, instruction: string) => AIPromptContext)
33
+ | undefined,
34
+ diffReviewOnEnd: true,
35
+ onError: (error: MilkdownError) => {
36
+ console.error(`[milkdown/ai] [${error.code}]`, error)
37
+ },
38
+ aiIcon: undefined as string | undefined,
39
+ },
40
+ 'aiProviderConfig'
41
+ )
42
+
43
+ /// Holds the AbortController and active-form label for the current AI
44
+ /// session (null/empty when idle). `label` is shown in the streaming
45
+ /// indicator. `lastInstruction`, `lastLabel`, `lastFrom`, `lastTo` are
46
+ /// kept after the session ends so the diff-actions Retry button can
47
+ /// re-run the same prompt on the same text range. `diffOwnedByAI` is
48
+ /// flipped on right before our `endStreamingCmd` activates diff review
49
+ /// and back off when the diff panel sees the diff close, so a manually
50
+ /// started diff review (via `startDiffReviewCmd`) doesn't inherit the
51
+ /// previous AI session's Retry affordance.
52
+ export const aiSessionCtx = $ctx(
53
+ {
54
+ abortController: null as AbortController | null,
55
+ label: '',
56
+ lastInstruction: '',
57
+ lastLabel: undefined as string | undefined,
58
+ lastFrom: -1,
59
+ lastTo: -1,
60
+ diffOwnedByAI: false,
61
+ },
62
+ 'aiSession'
63
+ )
64
+
65
+ // ---------------------------------------------------------------------------
66
+ // Helpers
67
+ // ---------------------------------------------------------------------------
68
+
69
+ function emitAIError(ctx: Ctx, error: MilkdownError): void {
70
+ const config = ctx.get(aiProviderConfig.key)
71
+ try {
72
+ config.onError(error)
73
+ } catch (handlerError) {
74
+ console.error('[milkdown/ai] onError handler failed:', handlerError)
75
+ }
76
+ }
77
+
78
+ /// Clear the live (`abortController`, `label`) portion of the session
79
+ /// while preserving the `last*` fields used by the diff-actions Retry
80
+ /// button.
81
+ function clearActiveSession(ctx: Ctx): void {
82
+ const current = ctx.get(aiSessionCtx.key)
83
+ ctx.set(aiSessionCtx.key, {
84
+ ...current,
85
+ abortController: null,
86
+ label: '',
87
+ })
88
+ }
89
+
90
+ // ---------------------------------------------------------------------------
91
+ // Async provider runner
92
+ // ---------------------------------------------------------------------------
93
+
94
+ async function runProvider(
95
+ ctx: Ctx,
96
+ provider: AIProvider,
97
+ promptContext: AIPromptContext,
98
+ abortController: AbortController
99
+ ): Promise<void> {
100
+ try {
101
+ const iterable = provider(promptContext, abortController.signal)
102
+ const commands = ctx.get(commandsCtx)
103
+ for await (const chunk of iterable) {
104
+ if (abortController.signal.aborted) break
105
+ commands.call(pushChunkCmd.key, chunk)
106
+ }
107
+ if (abortController.signal.aborted) return
108
+ // Streaming complete — hand off to diff review if configured. The
109
+ // ownership flag is flipped *before* the dispatch so the diff-actions
110
+ // panel reads `true` during the same transaction's update cycle.
111
+ // If the dispatch is rejected (e.g. host code already ended the
112
+ // streaming session), revert the flag so the next manually-started
113
+ // diff review isn't misclassified as AI-owned — `clearActiveSession`
114
+ // intentionally preserves `diffOwnedByAI` because the panel is
115
+ // responsible for clearing it on the diff true→false edge, but no
116
+ // such edge fires when the dispatch never landed.
117
+ const config = ctx.get(aiProviderConfig.key)
118
+ if (config.diffReviewOnEnd) {
119
+ const cur = ctx.get(aiSessionCtx.key)
120
+ ctx.set(aiSessionCtx.key, { ...cur, diffOwnedByAI: true })
121
+ }
122
+ const dispatched = commands.call(endStreamingCmd.key, {
123
+ diffReview: config.diffReviewOnEnd,
124
+ })
125
+ if (config.diffReviewOnEnd && !dispatched) {
126
+ const cur = ctx.get(aiSessionCtx.key)
127
+ ctx.set(aiSessionCtx.key, { ...cur, diffOwnedByAI: false })
128
+ }
129
+ } catch (error) {
130
+ if (abortController.signal.aborted) return
131
+ const milkdownError = aiProviderError(error)
132
+ emitAIError(ctx, milkdownError)
133
+ const commands = ctx.get(commandsCtx)
134
+ commands.call(abortStreamingCmd.key, { keep: false })
135
+ } finally {
136
+ // Only clean up if this session is still the active one. If the
137
+ // user aborted and immediately started a new session, the new
138
+ // session owns the ctx now and we must not clobber it.
139
+ const current = ctx.get(aiSessionCtx.key)
140
+ if (current.abortController === abortController) {
141
+ clearActiveSession(ctx)
142
+ }
143
+ }
144
+ }
145
+
146
+ // ---------------------------------------------------------------------------
147
+ // Commands
148
+ // ---------------------------------------------------------------------------
149
+
150
+ /// Start an AI session: capture context, start streaming, and call the
151
+ /// provider asynchronously. The command returns synchronously; the
152
+ /// provider runs in the background.
153
+ ///
154
+ /// When the user has a text selection, the streamed output replaces the
155
+ /// selected text. The provider also receives the selected text in
156
+ /// `AIPromptContext.selection` for context-aware generation.
157
+ export const runAICmd = $command('RunAI', (ctx) => {
158
+ return (options?: RunAIOptions) => (state, dispatch) => {
159
+ if (!options?.instruction) return false
160
+
161
+ const config = ctx.get(aiProviderConfig.key)
162
+ if (!config.provider) return false
163
+
164
+ // Reject if a session is already running, streaming is active, or
165
+ // diff review is active (the diff plugin blocks non-diff transactions,
166
+ // so streaming flushes would be silently rejected).
167
+ const session = ctx.get(aiSessionCtx.key)
168
+ if (session.abortController) return false
169
+ if (streamingPluginKey.getState(state)?.active) return false
170
+ if (diffPluginKey.getState(state)?.active) return false
171
+
172
+ // Dry-run: when dispatch is undefined, ProseMirror is probing
173
+ // whether this command can execute. All precondition checks above
174
+ // are side-effect-free so we can return true here.
175
+ if (!dispatch) return true
176
+
177
+ // Set the session label before starting the streaming plugin, so
178
+ // the streaming-indicator widget reads the right label when its
179
+ // decoration is first built. Empty string means "no caller-supplied
180
+ // label" — the indicator widget falls through to its configured
181
+ // `fallbackLabel`. `lastInstruction` / `lastLabel` are also stored
182
+ // here so the diff-actions Retry button can re-run the same prompt
183
+ // later.
184
+ const abortController = new AbortController()
185
+ const { from, to } = state.selection
186
+ ctx.set(aiSessionCtx.key, {
187
+ abortController,
188
+ label: options.label ?? '',
189
+ lastInstruction: options.instruction,
190
+ lastLabel: options.label,
191
+ lastFrom: from,
192
+ lastTo: to,
193
+ // Reset every run; only the success path that hands off to diff
194
+ // review flips it back on.
195
+ diffOwnedByAI: false,
196
+ })
197
+
198
+ // Start streaming — replaces the selection if non-empty.
199
+ const commands = ctx.get(commandsCtx)
200
+ const insertAt = state.selection.empty
201
+ ? ('cursor' as const)
202
+ : ('selection' as const)
203
+ if (!commands.call(startStreamingCmd.key, { insertAt })) {
204
+ clearActiveSession(ctx)
205
+ return false
206
+ }
207
+
208
+ // Everything after startStreamingCmd is wrapped in try/catch: if
209
+ // buildContext or anything else throws, we must abort the streaming
210
+ // session to avoid leaving the editor locked with no way to recover.
211
+ let promptContext: AIPromptContext
212
+ try {
213
+ const buildContext = config.buildContext ?? defaultBuildContext
214
+ promptContext = buildContext(ctx, options.instruction)
215
+ } catch (error) {
216
+ const milkdownError = aiBuildContextError(error)
217
+ emitAIError(ctx, milkdownError)
218
+ commands.call(abortStreamingCmd.key, { keep: false })
219
+ clearActiveSession(ctx)
220
+ return false
221
+ }
222
+
223
+ // Fire-and-forget: the provider pushes chunks asynchronously.
224
+ // startStreamingCmd already dispatched its own transaction — we
225
+ // must NOT dispatch state.tr here as it would overwrite the
226
+ // streaming plugin's state with a stale doc.
227
+ void runProvider(ctx, config.provider, promptContext, abortController)
228
+
229
+ return true
230
+ }
231
+ })
232
+
233
+ /// Abort the current AI session. Signals the provider to stop and
234
+ /// delegates to `abortStreamingCmd` if streaming is still active.
235
+ /// Returns true whenever an AI session was actually cleaned up.
236
+ export const abortAICmd = $command('AbortAI', (ctx) => {
237
+ return (options?: { keep?: boolean }) => (state, dispatch) => {
238
+ const session = ctx.get(aiSessionCtx.key)
239
+ // Dry-run: return whether there's something to abort, without
240
+ // performing any side effects.
241
+ if (!dispatch) return !!session.abortController
242
+
243
+ if (!session.abortController) return false
244
+
245
+ session.abortController.abort()
246
+ clearActiveSession(ctx)
247
+
248
+ // Only call abortStreamingCmd if the streaming plugin is still
249
+ // active — it may have already finished/errored by the time the
250
+ // user clicks abort.
251
+ if (streamingPluginKey.getState(state)?.active) {
252
+ const commands = ctx.get(commandsCtx)
253
+ commands.call(abortStreamingCmd.key, options)
254
+ }
255
+ return true
256
+ }
257
+ })
@@ -0,0 +1,45 @@
1
+ import type { Ctx } from '@milkdown/kit/ctx'
2
+
3
+ import { editorViewCtx, serializerCtx } from '@milkdown/kit/core'
4
+
5
+ import type { AIPromptContext } from './types'
6
+
7
+ /// Default prompt context builder. Serializes the full document and
8
+ /// the current selection (if any) as markdown, then pairs them with
9
+ /// the user instruction.
10
+ export function defaultBuildContext(
11
+ ctx: Ctx,
12
+ instruction: string
13
+ ): AIPromptContext {
14
+ const view = ctx.get(editorViewCtx)
15
+ const serializer = ctx.get(serializerCtx)
16
+ const { state } = view
17
+
18
+ // Full document as markdown
19
+ const document = serializer(state.doc)
20
+
21
+ // Selected text as markdown (empty if selection is collapsed).
22
+ // For block-level selections (whole paragraphs, list items, etc.)
23
+ // we wrap the slice content in a doc node and serialize it. For
24
+ // inline-only selections (text inside a single paragraph),
25
+ // createAndFill on the doc type returns null because inline content
26
+ // isn't valid as direct doc children — wrap it in a paragraph first
27
+ // so marks (bold, italic, links) survive into the markdown output.
28
+ let selection = ''
29
+ if (!state.selection.empty) {
30
+ const { from, to } = state.selection
31
+ const slice = state.doc.slice(from, to)
32
+ const { schema } = state.doc.type
33
+ let wrapper = schema.topNodeType.createAndFill(null, slice.content)
34
+ if (!wrapper) {
35
+ const paragraph = schema.nodes.paragraph?.createAndFill(
36
+ null,
37
+ slice.content
38
+ )
39
+ if (paragraph) wrapper = schema.topNodeType.createAndFill(null, paragraph)
40
+ }
41
+ selection = wrapper ? serializer(wrapper) : state.doc.textBetween(from, to)
42
+ }
43
+
44
+ return { document, selection, instruction }
45
+ }
@@ -0,0 +1,95 @@
1
+ import { commandsCtx } from '@milkdown/kit/core'
2
+ import { acceptAllDiffsCmd, diffPluginKey } from '@milkdown/kit/plugin/diff'
3
+ import { Plugin, PluginKey } from '@milkdown/kit/prose/state'
4
+ import { $prose } from '@milkdown/kit/utils'
5
+
6
+ import type { AIDiffActionsConfig } from '../types'
7
+
8
+ import {
9
+ clearIcon as defaultRejectIcon,
10
+ confirmIcon as defaultAcceptIcon,
11
+ enterKeyIcon as defaultEnterKeyIcon,
12
+ retryIcon as defaultRetryIcon,
13
+ } from '../../../icons'
14
+ import { aiSessionCtx } from '../commands'
15
+ import { DiffActionsPanelView, type ResolvedDiffActionsConfig } from './view'
16
+
17
+ const diffActionsPanelKey = new PluginKey('CREPE_AI_DIFF_ACTIONS_PANEL')
18
+
19
+ export const DEFAULT_DIFF_ACTIONS_RETRY_LABEL = 'Retry'
20
+ export const DEFAULT_DIFF_ACTIONS_REJECT_ALL_LABEL = 'Reject all'
21
+ export const DEFAULT_DIFF_ACTIONS_ACCEPT_ALL_LABEL = 'Accept all'
22
+
23
+ function detectModSymbol(): string {
24
+ if (typeof navigator === 'undefined') return '⌘'
25
+ // `userAgentData.platform` is the modern accessor; fall back to the
26
+ // legacy `platform` / `userAgent` strings for older browsers and
27
+ // jsdom-based test environments.
28
+ const ua = navigator as unknown as {
29
+ userAgentData?: { platform?: string }
30
+ platform?: string
31
+ userAgent?: string
32
+ }
33
+ const platform =
34
+ ua.userAgentData?.platform ?? ua.platform ?? ua.userAgent ?? ''
35
+ return /mac|iphone|ipad|ipod/i.test(platform) ? '⌘' : 'Ctrl'
36
+ }
37
+
38
+ /// Detected per renderer process. Override via
39
+ /// `AIDiffActionsConfig.modSymbol` when the UI should diverge from the
40
+ /// runtime platform (e.g. always render `⌘` in a macOS-themed product).
41
+ export const DEFAULT_DIFF_ACTIONS_MOD_SYMBOL = detectModSymbol()
42
+
43
+ interface DiffActionsPluginOptions {
44
+ config?: AIDiffActionsConfig
45
+ /// The enter-key icon is owned by the top-level `AIFeatureConfig` so a
46
+ /// single override applies to both the instruction tooltip and this
47
+ /// panel's shortcut chip.
48
+ enterKeyIcon?: string
49
+ }
50
+
51
+ function resolveDiffActionsConfig(
52
+ options: DiffActionsPluginOptions
53
+ ): ResolvedDiffActionsConfig {
54
+ const { config, enterKeyIcon } = options
55
+ return {
56
+ retryLabel: config?.retryLabel ?? DEFAULT_DIFF_ACTIONS_RETRY_LABEL,
57
+ rejectAllLabel:
58
+ config?.rejectAllLabel ?? DEFAULT_DIFF_ACTIONS_REJECT_ALL_LABEL,
59
+ acceptAllLabel:
60
+ config?.acceptAllLabel ?? DEFAULT_DIFF_ACTIONS_ACCEPT_ALL_LABEL,
61
+ retryIcon: config?.retryIcon ?? defaultRetryIcon,
62
+ rejectIcon: config?.rejectIcon ?? defaultRejectIcon,
63
+ acceptIcon: config?.acceptIcon ?? defaultAcceptIcon,
64
+ enterKeyIcon: enterKeyIcon ?? defaultEnterKeyIcon,
65
+ modSymbol: config?.modSymbol ?? DEFAULT_DIFF_ACTIONS_MOD_SYMBOL,
66
+ }
67
+ }
68
+
69
+ export function diffActionsPanelPlugin(options: DiffActionsPluginOptions = {}) {
70
+ const resolved = resolveDiffActionsConfig(options)
71
+
72
+ return $prose((ctx) => {
73
+ return new Plugin({
74
+ key: diffActionsPanelKey,
75
+ view(view) {
76
+ return new DiffActionsPanelView(ctx, view, resolved)
77
+ },
78
+ props: {
79
+ handleKeyDown(view, event) {
80
+ if (event.key !== 'Enter') return false
81
+ if (!(event.metaKey || event.ctrlKey)) return false
82
+ if (!diffPluginKey.getState(view.state)?.active) return false
83
+ // Only intercept Mod-Enter for diffs this AI session started.
84
+ // Manual `startDiffReviewCmd` flows shouldn't have their key
85
+ // bindings mutated just because the AI feature is loaded.
86
+ if (!ctx.get(aiSessionCtx.key).diffOwnedByAI) return false
87
+ event.preventDefault()
88
+ const commands = ctx.get(commandsCtx)
89
+ commands.call(acceptAllDiffsCmd.key)
90
+ return true
91
+ },
92
+ },
93
+ })
94
+ })
95
+ }
@@ -0,0 +1,237 @@
1
+ import type { Ctx } from '@milkdown/kit/ctx'
2
+ import type { Node } from '@milkdown/kit/prose/model'
3
+ import type { PluginView } from '@milkdown/kit/prose/state'
4
+ import type { EditorView } from '@milkdown/kit/prose/view'
5
+
6
+ import { commandsCtx, editorViewCtx } from '@milkdown/kit/core'
7
+ import {
8
+ acceptAllDiffsCmd,
9
+ clearDiffReviewCmd,
10
+ diffPluginKey,
11
+ } from '@milkdown/kit/plugin/diff'
12
+ import { TextSelection } from '@milkdown/kit/prose/state'
13
+ import DOMPurify from 'dompurify'
14
+
15
+ import { aiSessionCtx, runAICmd } from '../commands'
16
+
17
+ const PANEL_CLASS = 'milkdown-ai-diff-actions'
18
+
19
+ /// Icons in `ResolvedDiffActionsConfig` are user-supplied SVG strings,
20
+ /// so route them through DOMPurify before assigning to `innerHTML` —
21
+ /// matches the sanitization the shared `Icon` component already does
22
+ /// for Vue-rendered icons.
23
+ function setSanitizedIcon(host: HTMLElement, svg: string): void {
24
+ host.innerHTML = DOMPurify.sanitize(svg.trim())
25
+ }
26
+
27
+ /// Fully resolved diff actions config — every field has a value, so the
28
+ /// view doesn't have to know about defaults.
29
+ export interface ResolvedDiffActionsConfig {
30
+ retryLabel: string
31
+ rejectAllLabel: string
32
+ acceptAllLabel: string
33
+ retryIcon: string
34
+ rejectIcon: string
35
+ acceptIcon: string
36
+ enterKeyIcon: string
37
+ modSymbol: string
38
+ }
39
+
40
+ function createIcon(svg: string): HTMLElement {
41
+ const span = document.createElement('span')
42
+ span.className = `${PANEL_CLASS}-icon`
43
+ setSanitizedIcon(span, svg)
44
+ return span
45
+ }
46
+
47
+ export class DiffActionsPanelView implements PluginView {
48
+ readonly #panel: HTMLElement
49
+ readonly #host: HTMLElement
50
+ readonly #retryBtn: HTMLButtonElement
51
+ readonly #config: ResolvedDiffActionsConfig
52
+ #visible = false
53
+ /// Tracks the diff plugin's `active` flag across transactions so we
54
+ /// can detect false→true / true→false edges independently of whether
55
+ /// the panel is actually being shown.
56
+ #diffActive = false
57
+ /// Doc snapshot at the moment diff review activated. If the live doc
58
+ /// drifts from this snapshot the user has accepted some per-change
59
+ /// diffs and the stored `lastFrom`/`lastTo` no longer point at the
60
+ /// original range — Retry is unsafe at that point.
61
+ #diffStartDoc: Node | null = null
62
+ /// Whether the active diff review came from this AI session's
63
+ /// streaming hand-off (vs being started manually via
64
+ /// `startDiffReviewCmd`). Captured at the false→true transition.
65
+ /// The panel only renders when this is true so it doesn't take over
66
+ /// non-AI diff flows that exist independently of the AI feature.
67
+ #ownedByAI = false
68
+
69
+ constructor(
70
+ readonly ctx: Ctx,
71
+ view: EditorView,
72
+ config: ResolvedDiffActionsConfig
73
+ ) {
74
+ this.#config = config
75
+ this.#host = this.#findHost(view)
76
+
77
+ const panel = document.createElement('div')
78
+ panel.className = PANEL_CLASS
79
+ panel.dataset.show = 'false'
80
+
81
+ this.#retryBtn = this.#makeButton(
82
+ 'retry',
83
+ config.retryIcon,
84
+ config.retryLabel,
85
+ this.#retry
86
+ )
87
+ panel.appendChild(this.#retryBtn)
88
+ panel.appendChild(
89
+ this.#makeButton(
90
+ 'reject',
91
+ config.rejectIcon,
92
+ config.rejectAllLabel,
93
+ this.#rejectAll
94
+ )
95
+ )
96
+ const acceptBtn = this.#makeButton(
97
+ 'accept',
98
+ config.acceptIcon,
99
+ config.acceptAllLabel,
100
+ this.#acceptAll
101
+ )
102
+ acceptBtn.appendChild(this.#makeShortcutChip())
103
+ panel.appendChild(acceptBtn)
104
+
105
+ this.#panel = panel
106
+ this.#host.appendChild(panel)
107
+ this.update(view)
108
+ }
109
+
110
+ #findHost(view: EditorView): HTMLElement {
111
+ return (view.dom.closest('.milkdown') as HTMLElement) ?? document.body
112
+ }
113
+
114
+ #makeButton(
115
+ variant: 'retry' | 'reject' | 'accept',
116
+ icon: string,
117
+ label: string,
118
+ onClick: () => void
119
+ ): HTMLButtonElement {
120
+ const btn = document.createElement('button')
121
+ btn.type = 'button'
122
+ btn.className = `${PANEL_CLASS}-btn ${PANEL_CLASS}-btn-${variant}`
123
+ btn.appendChild(createIcon(icon))
124
+ const text = document.createElement('span')
125
+ text.textContent = label
126
+ btn.appendChild(text)
127
+ btn.addEventListener('mousedown', (e) => e.preventDefault())
128
+ btn.addEventListener('click', (e) => {
129
+ e.preventDefault()
130
+ e.stopPropagation()
131
+ onClick()
132
+ })
133
+ return btn
134
+ }
135
+
136
+ #makeShortcutChip(): HTMLElement {
137
+ const shortcut = document.createElement('span')
138
+ shortcut.className = `${PANEL_CLASS}-shortcut`
139
+ const cmd = document.createElement('span')
140
+ cmd.textContent = this.#config.modSymbol
141
+ const enter = document.createElement('span')
142
+ enter.className = `${PANEL_CLASS}-shortcut-icon`
143
+ setSanitizedIcon(enter, this.#config.enterKeyIcon)
144
+ shortcut.append(cmd, enter)
145
+ return shortcut
146
+ }
147
+
148
+ #retry = (): void => {
149
+ const session = this.ctx.get(aiSessionCtx.key)
150
+ if (!session.lastInstruction) return
151
+ // Refuse to retry when the active diff didn't originate from this
152
+ // AI session, or once the user has accepted any individual diff —
153
+ // `clearDiffReviewCmd` only exits diff mode, it doesn't roll back
154
+ // accepted changes, so the stored range would point at shifted text.
155
+ if (!this.#ownedByAI || !this.#canRetry()) return
156
+
157
+ const commands = this.ctx.get(commandsCtx)
158
+ commands.call(clearDiffReviewCmd.key)
159
+ const editorView = this.ctx.get(editorViewCtx)
160
+ const { doc } = editorView.state
161
+ const from = Math.min(Math.max(session.lastFrom, 0), doc.content.size)
162
+ const to = Math.min(Math.max(session.lastTo, 0), doc.content.size)
163
+ editorView.dispatch(
164
+ editorView.state.tr.setSelection(
165
+ TextSelection.create(editorView.state.doc, from, to)
166
+ )
167
+ )
168
+ commands.call(runAICmd.key, {
169
+ instruction: session.lastInstruction,
170
+ label: session.lastLabel,
171
+ })
172
+ }
173
+
174
+ #canRetry(): boolean {
175
+ if (!this.#diffStartDoc) return false
176
+ const editorView = this.ctx.get(editorViewCtx)
177
+ return editorView.state.doc.eq(this.#diffStartDoc)
178
+ }
179
+
180
+ #rejectAll = (): void => {
181
+ this.ctx.get(commandsCtx).call(clearDiffReviewCmd.key)
182
+ }
183
+
184
+ #acceptAll = (): void => {
185
+ this.ctx.get(commandsCtx).call(acceptAllDiffsCmd.key)
186
+ }
187
+
188
+ update(view: EditorView): void {
189
+ const diffActive = !!diffPluginKey.getState(view.state)?.active
190
+
191
+ if (diffActive !== this.#diffActive) {
192
+ this.#diffActive = diffActive
193
+ if (diffActive) {
194
+ // false → true. Capture ownership now; from this point on any
195
+ // doc drift means the user has accepted individual diffs.
196
+ const session = this.ctx.get(aiSessionCtx.key)
197
+ this.#ownedByAI = session.diffOwnedByAI
198
+ this.#diffStartDoc = view.state.doc
199
+ } else {
200
+ // true → false. Clear local state and the session-level flag so
201
+ // a subsequent manual diff doesn't pick up stale ownership.
202
+ this.#ownedByAI = false
203
+ this.#diffStartDoc = null
204
+ const session = this.ctx.get(aiSessionCtx.key)
205
+ if (session.diffOwnedByAI) {
206
+ this.ctx.set(aiSessionCtx.key, {
207
+ ...session,
208
+ diffOwnedByAI: false,
209
+ })
210
+ }
211
+ }
212
+ }
213
+
214
+ // Render only for AI-owned diff reviews. Non-AI diffs (started via
215
+ // `startDiffReviewCmd` directly) are left to whatever UX the host
216
+ // had in place before this feature shipped.
217
+ const shouldShow = diffActive && this.#ownedByAI
218
+ if (shouldShow !== this.#visible) {
219
+ this.#visible = shouldShow
220
+ this.#panel.dataset.show = shouldShow ? 'true' : 'false'
221
+ }
222
+
223
+ if (shouldShow) {
224
+ // Re-evaluate Retry every transaction so the button disables the
225
+ // moment a per-change accept shifts the doc out of its initial
226
+ // form.
227
+ const session = this.ctx.get(aiSessionCtx.key)
228
+ const docUntouched =
229
+ !!this.#diffStartDoc && view.state.doc.eq(this.#diffStartDoc)
230
+ this.#retryBtn.disabled = !session.lastInstruction || !docUntouched
231
+ }
232
+ }
233
+
234
+ destroy(): void {
235
+ this.#panel.remove()
236
+ }
237
+ }