+
+
+
+ GhostTextOverlay
+ v-if="suggestion && cursorRect"
+ :suggestion="suggestion"
+ :position="cursorRect"
+ @accept="acceptSuggestion"
+ @dismiss="dismissSuggestion"
+ /GhostTextOverlay
diff --git a/src/plugins/inlineSuggestionPlugin.ts b/src/plugins/inlineSuggestionPlugin.ts
new file mode 100644
index 0000000..dc4060c
--- /dev/null
+++ b/src/plugins/inlineSuggestionPlugin.ts
@@ -0,0 +1,104 @@
+import { Plugin, PluginKey } from '@milkdown/prose/state';
+import { EditorView } from '@milkdown/prose/view';
+
+const INLINE_SUGGESTION_KEY = new PluginKey('inline-suggestion');
+const DEBOUNCE_MS = 150;
+let debounceTimer = null;
+let currentSuggestion = '';
+let suggestionPos = { from: 0, to: 0 };
+
+interface InlineSuggestionOptions {
+ apiUrl?: string;
+}
+
+function createInlineSuggestionPlugin(options: InlineSuggestionOptions = {}) {
+ const apiUrl = options.apiUrl || 'http://localhost:8000/v1/completions';
+
+ return new Plugin({
+ key: INLINE_SUGGESTION_KEY,
+ state: {
+ init: () => ({ suggestion: '', visible: false }),
+ apply: (tr, value) => {
+ if (!tr.docChanged) return value;
+ const { from, to } = tr.selection;
+ if (from === suggestionPos.from && to === suggestionPos.to) {
+ return value;
+ }
+ return { suggestion: '', visible: false };
+ },
+ },
+ props: {
+ handleKeyDown: (view: EditorView, event: KeyboardEvent) => {
+ if (event.key === 'Tab' && INLINE_SUGGESTION_KEY.getState(view.state).visible) {
+ event.preventDefault();
+ const { suggestion } = INLINE_SUGGESTION_KEY.getState(view.state);
+ if (suggestion) {
+ view.dispatch(view.state.tr.insertText(suggestion, view.state.selection.from));
+ currentSuggestion = '';
+ return true;
+ }
+ }
+ if (event.key === 'Escape') {
+ const state = INLINE_SUGGESTION_KEY.getState(view.state);
+ if (state.visible) {
+ view.dispatch(view.state.tr.setMeta(INLINE_SUGGESTION_KEY, { suggestion: '', visible: false }));
+ currentSuggestion = '';
+ return true;
+ }
+ }
+ return false;
+ },
+ },
+ appendTransaction: (transactions, oldState, newState) => {
+ const lastTr = transactions[transactions.length - 1];
+ if (!lastTr || !lastTr.docChanged) return null;
+
+ clearTimeout(debounceTimer);
+ debounceTimer = setTimeout(async () => {
+ const { from, to } = newState.selection;
+ const prefix = newState.doc.textBetween(0, from);
+ const suffix = newState.doc.textBetween(to, newState.doc.content.size);
+
+ try {
+ const res = await fetch(apiUrl, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ prefix, suffix, languageId: 'markdown' }),
+ });
+
+ if (!res.ok) return;
+
+ const reader = res.body?.getReader();
+ if (!reader) return;
+
+ let text = '';
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) break;
+ const chunk = new TextDecoder().decode(value);
+ const lines = chunk.split('\n').filter(l => l.startsWith('data: '));
+ for (const line of lines) {
+ try {
+ const data = JSON.parse(line.slice(6));
+ if (data.content) text += data.content;
+ if (data.done) break;
+ } catch {}
+ }
+ }
+
+ if (text && newState.selection.from === from) {
+ currentSuggestion = text;
+ suggestionPos = { from, to: from + text.length };
+ newState.apply(newState.tr.setMeta(INLINE_SUGGESTION_KEY, { suggestion: text, visible: true }));
+ }
+ } catch (e) {
+ console.error('Inline suggestion error:', e);
+ }
+ }, DEBOUNCE_MS);
+
+ return null;
+ },
+ });
+}
+
+export { createInlineSuggestionPlugin, INLINE_SUGGESTION_KEY };