mirror of
https://github.com/ahmetildirim/obsidian-inscribe.git
synced 2026-07-22 05:44:10 +00:00
feat: implement inline suggestion system with customizable settings and text segmentation strategies
This commit is contained in:
parent
2f7e91df02
commit
eaffc10136
11 changed files with 555 additions and 504 deletions
|
|
@ -57,7 +57,7 @@ export default class CompletionService {
|
|||
|
||||
const lastChar = currentLine[cursor.ch - 1];
|
||||
// Check if the last character is not a space
|
||||
// if (lastChar !== " ") return;
|
||||
if (lastChar !== " ") return;
|
||||
|
||||
const prompt = preparePrompt(activeEditor.editor, options.userPrompt);
|
||||
this.notifyCompletionStatus(true);
|
||||
|
|
|
|||
93
src/extension/fetcher.ts
Normal file
93
src/extension/fetcher.ts
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
// Debounced suggestion fetching
|
||||
|
||||
import { ViewPlugin, EditorView, ViewUpdate } from '@codemirror/view';
|
||||
import { EditorState } from '@codemirror/state';
|
||||
import { Suggestion } from './types';
|
||||
import { suggestionSessionState, SuggestionUpdateEffect } from './session-state';
|
||||
|
||||
// Creates a debounced fetcher for suggestions.
|
||||
export function createDebouncedFetcher(
|
||||
fetch: (state: EditorState) => AsyncGenerator<Suggestion>,
|
||||
getDelay: () => number,
|
||||
autoTriggerEnabled: boolean = true
|
||||
) {
|
||||
let activeRequest = true;
|
||||
let timeoutId: ReturnType<typeof setTimeout>;
|
||||
|
||||
// Throttled fetch that waits for the debounce interval.
|
||||
const throttledFetch = async function* (state: EditorState) {
|
||||
clearTimeout(timeoutId);
|
||||
activeRequest = true;
|
||||
await new Promise((resolve) => {
|
||||
timeoutId = setTimeout(resolve, getDelay());
|
||||
});
|
||||
if (activeRequest) yield* fetch(state);
|
||||
};
|
||||
|
||||
// Immediate fetch without debounce (for manual triggers).
|
||||
const immediateFetch = async function* (state: EditorState) {
|
||||
activeRequest = true;
|
||||
if (activeRequest) yield* fetch(state);
|
||||
};
|
||||
|
||||
// Plugin that initiates suggestion fetching on document changes.
|
||||
const fetcherPlugin = ViewPlugin.fromClass(
|
||||
class {
|
||||
private currentRequestId = 0;
|
||||
|
||||
async update(update: ViewUpdate) {
|
||||
const state = update.state;
|
||||
// Only trigger fetch if auto-trigger is enabled and there is no active suggestion.
|
||||
if (!autoTriggerEnabled || !update.docChanged || state.field(suggestionSessionState).remainingText)
|
||||
return;
|
||||
|
||||
const requestId = ++this.currentRequestId;
|
||||
for await (const suggestion of throttledFetch(state)) {
|
||||
// Ignore stale requests.
|
||||
if (requestId !== this.currentRequestId) return;
|
||||
update.view.dispatch({
|
||||
effects: SuggestionUpdateEffect.of({
|
||||
content: suggestion.text,
|
||||
document: state.doc,
|
||||
anchor: state.selection.main.head,
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Method to manually trigger suggestions (exposed for hotkey use).
|
||||
async triggerSuggestion(view: EditorView) {
|
||||
const state = view.state;
|
||||
// Cancel any active suggestion first.
|
||||
view.dispatch({
|
||||
effects: SuggestionUpdateEffect.of({
|
||||
content: null,
|
||||
document: null,
|
||||
anchor: null,
|
||||
}),
|
||||
});
|
||||
|
||||
const requestId = ++this.currentRequestId;
|
||||
for await (const suggestion of immediateFetch(state)) {
|
||||
// Ignore stale requests.
|
||||
if (requestId !== this.currentRequestId) return;
|
||||
view.dispatch({
|
||||
effects: SuggestionUpdateEffect.of({
|
||||
content: suggestion.text,
|
||||
document: state.doc,
|
||||
anchor: state.selection.main.head,
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
return {
|
||||
fetcherPlugin,
|
||||
terminate: () => {
|
||||
activeRequest = false;
|
||||
clearTimeout(timeoutId);
|
||||
},
|
||||
};
|
||||
}
|
||||
82
src/extension/handlers.ts
Normal file
82
src/extension/handlers.ts
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
// User interaction handlers for accepting and triggering suggestions
|
||||
|
||||
import { ViewPlugin, EditorView, keymap } from '@codemirror/view';
|
||||
import { EditorState, EditorSelection, Prec } from '@codemirror/state';
|
||||
import { SplitStrategy, InlineCompletionOptions } from './types';
|
||||
import { TextSplitStrategies } from './text-strategies';
|
||||
import { suggestionSessionState, SuggestionUpdateEffect } from './session-state';
|
||||
|
||||
// Helper to create a transaction that inserts completion text.
|
||||
function insertCompletion(state: EditorState, text: string) {
|
||||
const cursorPos = state.selection.main.head;
|
||||
return {
|
||||
...state.changeByRange(() => ({
|
||||
changes: { from: cursorPos, insert: text },
|
||||
range: EditorSelection.cursor(cursorPos + text.length),
|
||||
})),
|
||||
userEvent: 'completion.accept',
|
||||
};
|
||||
}
|
||||
|
||||
// Returns a key binding that accepts the current suggestion.
|
||||
export function createAcceptanceHandler(
|
||||
terminateFetch: () => void,
|
||||
hotkey: string,
|
||||
getOptions: () => InlineCompletionOptions
|
||||
) {
|
||||
return Prec.highest(
|
||||
keymap.of([
|
||||
{
|
||||
key: hotkey,
|
||||
run: (view: EditorView) => {
|
||||
const session = view.state.field(suggestionSessionState);
|
||||
if (!session.remainingText) return false;
|
||||
|
||||
// Always obtain the current split strategy from getOptions.
|
||||
const dynamicOptions = getOptions();
|
||||
const segmentationKey = dynamicOptions.splitStrategy ?? 'word';
|
||||
const { accepted, remaining } =
|
||||
TextSplitStrategies[segmentationKey](session.remainingText);
|
||||
|
||||
if (!accepted) return false;
|
||||
|
||||
// Insert the accepted suggestion text.
|
||||
view.dispatch({
|
||||
...insertCompletion(view.state, accepted),
|
||||
effects: SuggestionUpdateEffect.of({
|
||||
content: remaining || null,
|
||||
document: remaining ? session.baselineDocument : null,
|
||||
anchor: remaining ? session.anchorPosition! + accepted.length : null,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!remaining) terminateFetch();
|
||||
return true;
|
||||
},
|
||||
},
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
// Returns a key binding that manually triggers suggestions.
|
||||
export function createTriggerHandler(
|
||||
fetcherPlugin: ViewPlugin<any>,
|
||||
hotkey: string
|
||||
) {
|
||||
return Prec.highest(
|
||||
keymap.of([
|
||||
{
|
||||
key: hotkey,
|
||||
run: (view: EditorView) => {
|
||||
// Get the fetcher plugin instance and trigger suggestion.
|
||||
const pluginInstance = view.plugin(fetcherPlugin);
|
||||
if (pluginInstance && 'triggerSuggestion' in pluginInstance) {
|
||||
(pluginInstance as any).triggerSuggestion(view);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
},
|
||||
])
|
||||
);
|
||||
}
|
||||
|
|
@ -6,478 +6,29 @@
|
|||
// - Debounced network requests
|
||||
// - Non-invasive suggestion rendering
|
||||
|
||||
import {
|
||||
ViewPlugin,
|
||||
EditorView,
|
||||
ViewUpdate,
|
||||
Decoration,
|
||||
WidgetType,
|
||||
keymap,
|
||||
} from '@codemirror/view';
|
||||
import {
|
||||
StateEffect,
|
||||
Text,
|
||||
StateField,
|
||||
EditorState,
|
||||
EditorSelection,
|
||||
Transaction,
|
||||
Prec,
|
||||
} from '@codemirror/state';
|
||||
import { EditorState } from '@codemirror/state';
|
||||
import { InlineCompletionConfig, Suggestion } from './types';
|
||||
import { suggestionSessionState } from './session-state';
|
||||
import { suggestionRenderer } from './renderer';
|
||||
import { createDebouncedFetcher } from './fetcher';
|
||||
import { createAcceptanceHandler, createTriggerHandler } from './handlers';
|
||||
|
||||
/* --------------------------------------------------------------------------
|
||||
Type Definitions
|
||||
---------------------------------------------------------------------------- */
|
||||
// Re-export types for external use
|
||||
export type { SplitStrategy, Suggestion, InlineCompletionConfig, InlineCompletionOptions } from './types';
|
||||
|
||||
// Supported segmentation strategies
|
||||
export type SplitStrategy = keyof typeof TextSplitStrategies;
|
||||
|
||||
// Inline suggestion structure – now only carries text.
|
||||
export interface Suggestion {
|
||||
text: string;
|
||||
}
|
||||
|
||||
// Inline completion configuration.
|
||||
export interface InlineCompletionConfig {
|
||||
fetchFunc: (
|
||||
state: EditorState
|
||||
) => AsyncGenerator<Suggestion> | Promise<Suggestion>;
|
||||
//(Optional) A static hotkey for accepting suggestions.
|
||||
acceptanceHotkey?: string;
|
||||
//(Optional) A static hotkey for manually triggering suggestions.
|
||||
triggerHotkey?: string;
|
||||
// A function that returns current options.
|
||||
getOptions: () => InlineCompletionOptions;
|
||||
}
|
||||
|
||||
export interface InlineCompletionOptions {
|
||||
delayMs?: number;
|
||||
splitStrategy?: SplitStrategy;
|
||||
}
|
||||
|
||||
// Internal state for the current suggestion session.
|
||||
interface SuggestionSession {
|
||||
fullText: string | null;
|
||||
remainingText: string | null;
|
||||
baselineDocument: Text | null;
|
||||
anchorPosition: number | null;
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------------------
|
||||
Text Segmentation Strategies
|
||||
---------------------------------------------------------------------------- */
|
||||
|
||||
// A set of text splitting functions used to determine how much of the
|
||||
// suggestion to accept when triggered.
|
||||
const TextSplitStrategies = {
|
||||
// Word-level segmentation (space-delimited).
|
||||
// Accepts text until (and including) the first space.
|
||||
word: (text: string) => {
|
||||
const nextSpace = text.indexOf(' ');
|
||||
return nextSpace === -1
|
||||
? { accepted: text, remaining: '' }
|
||||
: {
|
||||
accepted: text.slice(0, nextSpace + 1),
|
||||
remaining: text.slice(nextSpace + 1),
|
||||
};
|
||||
},
|
||||
|
||||
// Sentence-level segmentation (punctuation followed by whitespace).
|
||||
sentence: (text: string) => {
|
||||
const match = text.match(/[.!?]\s+/);
|
||||
if (match && match.index !== undefined) {
|
||||
return {
|
||||
accepted: text.slice(0, match.index + 1),
|
||||
remaining: text.slice(match.index + 1),
|
||||
};
|
||||
}
|
||||
return { accepted: text, remaining: '' };
|
||||
},
|
||||
|
||||
// Paragraph-level segmentation (double newline).
|
||||
paragraph: (text: string) => {
|
||||
const paragraphEnd = text.indexOf('\n\n');
|
||||
return paragraphEnd === -1
|
||||
? { accepted: text, remaining: '' }
|
||||
: {
|
||||
accepted: text.slice(0, paragraphEnd + 2),
|
||||
remaining: text.slice(paragraphEnd + 2),
|
||||
};
|
||||
},
|
||||
|
||||
// Atomic acceptance – consume the entire suggestion.
|
||||
full: (text: string) => ({ accepted: text, remaining: '' }),
|
||||
} as const;
|
||||
|
||||
/* --------------------------------------------------------------------------
|
||||
Suggestion Session State Management
|
||||
---------------------------------------------------------------------------- */
|
||||
|
||||
// Effect to update the suggestion session state.
|
||||
// A `null` content signals a reset.
|
||||
const SuggestionUpdateEffect = StateEffect.define<{
|
||||
content: string | null;
|
||||
document: Text | null;
|
||||
anchor: number | null;
|
||||
}>();
|
||||
|
||||
// The state field that holds the current suggestion session.
|
||||
const suggestionSessionState = StateField.define<SuggestionSession>({
|
||||
create: () => getResetSession(),
|
||||
|
||||
update(session, transaction) {
|
||||
// Process explicit session update effects.
|
||||
const effect = transaction.effects.find((e) =>
|
||||
e.is(SuggestionUpdateEffect)
|
||||
);
|
||||
if (effect) return updateSessionFromEffect(effect.value);
|
||||
|
||||
// If the document has changed, adjust the session.
|
||||
if (
|
||||
transaction.docChanged &&
|
||||
session.remainingText &&
|
||||
session.anchorPosition !== null
|
||||
) {
|
||||
return updateSessionOnDocumentChange(session, transaction);
|
||||
}
|
||||
|
||||
// If there is an active suggestion but the cursor has moved, cancel it.
|
||||
if (session.remainingText !== null && session.anchorPosition !== null) {
|
||||
return updateSessionOnCursorDrift(session, transaction);
|
||||
}
|
||||
|
||||
return session;
|
||||
},
|
||||
});
|
||||
|
||||
// Creates a fresh, empty suggestion session.
|
||||
function getResetSession(): SuggestionSession {
|
||||
return {
|
||||
fullText: null,
|
||||
remainingText: null,
|
||||
baselineDocument: null,
|
||||
anchorPosition: null,
|
||||
};
|
||||
}
|
||||
|
||||
// Update session state based on an incoming effect.
|
||||
function updateSessionFromEffect(effect: {
|
||||
content: string | null;
|
||||
document: Text | null;
|
||||
anchor: number | null;
|
||||
}): SuggestionSession {
|
||||
return effect.content === null
|
||||
? getResetSession()
|
||||
: initializeSession(effect as {
|
||||
content: string;
|
||||
document: Text;
|
||||
anchor: number;
|
||||
});
|
||||
}
|
||||
|
||||
// Initializes a new suggestion session with provided effect data.
|
||||
function initializeSession(effect: {
|
||||
content: string;
|
||||
document: Text;
|
||||
anchor: number;
|
||||
}): SuggestionSession {
|
||||
return {
|
||||
fullText: effect.content,
|
||||
remainingText: effect.content,
|
||||
baselineDocument: effect.document,
|
||||
anchorPosition: effect.anchor,
|
||||
};
|
||||
}
|
||||
|
||||
// Adjust the suggestion session in response to document changes.
|
||||
function updateSessionOnDocumentChange(
|
||||
session: SuggestionSession,
|
||||
transaction: Transaction
|
||||
): SuggestionSession {
|
||||
let insertedContent = '';
|
||||
let insertionAtAnchor = false;
|
||||
|
||||
// Iterate over document changes to detect an insertion at the suggestion's anchor.
|
||||
transaction.changes.iterChanges((fromA, toA, _fromB, _toB, inserted) => {
|
||||
if (fromA === session.anchorPosition && toA === fromA) {
|
||||
insertedContent = inserted.toString();
|
||||
insertionAtAnchor = true;
|
||||
}
|
||||
});
|
||||
|
||||
if (!insertionAtAnchor || !session.remainingText) {
|
||||
return invalidateSession(session);
|
||||
}
|
||||
|
||||
// Verify the inserted text matches the pending suggestion.
|
||||
if (session.remainingText.startsWith(insertedContent)) {
|
||||
return advanceSession(session, insertedContent.length);
|
||||
}
|
||||
|
||||
return invalidateSession(session);
|
||||
}
|
||||
|
||||
// Advance the session by consuming accepted text.
|
||||
function advanceSession(
|
||||
session: SuggestionSession,
|
||||
consumedLength: number
|
||||
): SuggestionSession {
|
||||
return {
|
||||
...session,
|
||||
remainingText:
|
||||
session.remainingText!.slice(consumedLength).length > 0
|
||||
? session.remainingText!.slice(consumedLength)
|
||||
: null,
|
||||
anchorPosition: session.anchorPosition! + consumedLength,
|
||||
};
|
||||
}
|
||||
|
||||
// Invalidate the session, effectively cancelling any pending suggestion.
|
||||
function invalidateSession(session: SuggestionSession): SuggestionSession {
|
||||
return {
|
||||
...session,
|
||||
remainingText: null,
|
||||
anchorPosition: null,
|
||||
};
|
||||
}
|
||||
|
||||
// Cancel the suggestion if the cursor has drifted away.
|
||||
function updateSessionOnCursorDrift(
|
||||
session: SuggestionSession,
|
||||
transaction: Transaction
|
||||
): SuggestionSession {
|
||||
return transaction.state.selection.main.head !== session.anchorPosition
|
||||
? invalidateSession(session)
|
||||
: session;
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------------------
|
||||
Suggestion Rendering (Visualization Layer)
|
||||
---------------------------------------------------------------------------- */
|
||||
|
||||
// Widget for rendering inline suggestion text.
|
||||
class SuggestionWidget extends WidgetType {
|
||||
static readonly CSS_CLASSES = ['cm-inline-prediction', 'inscribe-inline-prediction'];
|
||||
|
||||
constructor(private readonly content: string) {
|
||||
super();
|
||||
}
|
||||
|
||||
toDOM(): HTMLElement {
|
||||
const span = document.createElement('span');
|
||||
span.classList.add(...SuggestionWidget.CSS_CLASSES);
|
||||
span.textContent = this.content;
|
||||
return span;
|
||||
}
|
||||
}
|
||||
|
||||
// Plugin that renders inline suggestion decorations.
|
||||
const suggestionRenderer = ViewPlugin.fromClass(
|
||||
class {
|
||||
decorations = Decoration.none;
|
||||
|
||||
update(update: ViewUpdate) {
|
||||
const session = update.state.field(suggestionSessionState);
|
||||
this.decorations = session.remainingText
|
||||
? this.createDecoration(update.view, session.remainingText)
|
||||
: Decoration.none;
|
||||
}
|
||||
|
||||
private createDecoration(view: EditorView, suggestionText: string) {
|
||||
const cursorPosition = view.state.selection.main.head;
|
||||
return Decoration.set([
|
||||
Decoration.widget({
|
||||
widget: new SuggestionWidget(suggestionText),
|
||||
side: 1,
|
||||
}).range(cursorPosition),
|
||||
]);
|
||||
}
|
||||
},
|
||||
{ decorations: (v) => v.decorations }
|
||||
);
|
||||
|
||||
/* --------------------------------------------------------------------------
|
||||
Suggestion Fetching (Debounced Fetcher)
|
||||
---------------------------------------------------------------------------- */
|
||||
|
||||
// Creates a debounced fetcher for suggestions.
|
||||
const createDebouncedFetcher = (
|
||||
fetch: (state: EditorState) => AsyncGenerator<Suggestion>,
|
||||
getDelay: () => number,
|
||||
autoTriggerEnabled: boolean = true
|
||||
) => {
|
||||
let activeRequest = true;
|
||||
let timeoutId: ReturnType<typeof setTimeout>;
|
||||
|
||||
// Throttled fetch that waits for the debounce interval.
|
||||
const throttledFetch = async function* (state: EditorState) {
|
||||
clearTimeout(timeoutId);
|
||||
activeRequest = true;
|
||||
await new Promise((resolve) => {
|
||||
timeoutId = setTimeout(resolve, getDelay());
|
||||
});
|
||||
if (activeRequest) yield* fetch(state);
|
||||
};
|
||||
|
||||
// Immediate fetch without debounce (for manual triggers).
|
||||
const immediateFetch = async function* (state: EditorState) {
|
||||
activeRequest = true;
|
||||
if (activeRequest) yield* fetch(state);
|
||||
};
|
||||
|
||||
// Plugin that initiates suggestion fetching on document changes.
|
||||
const fetcherPlugin = ViewPlugin.fromClass(
|
||||
class {
|
||||
private currentRequestId = 0;
|
||||
|
||||
async update(update: ViewUpdate) {
|
||||
const state = update.state;
|
||||
// Only trigger fetch if auto-trigger is enabled and there is no active suggestion.
|
||||
if (!autoTriggerEnabled || !update.docChanged || state.field(suggestionSessionState).remainingText)
|
||||
return;
|
||||
|
||||
const requestId = ++this.currentRequestId;
|
||||
for await (const suggestion of throttledFetch(state)) {
|
||||
// Ignore stale requests.
|
||||
if (requestId !== this.currentRequestId) return;
|
||||
update.view.dispatch({
|
||||
effects: SuggestionUpdateEffect.of({
|
||||
content: suggestion.text,
|
||||
document: state.doc,
|
||||
anchor: state.selection.main.head,
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Method to manually trigger suggestions (exposed for hotkey use).
|
||||
async triggerSuggestion(view: EditorView) {
|
||||
const state = view.state;
|
||||
// Cancel any active suggestion first.
|
||||
view.dispatch({
|
||||
effects: SuggestionUpdateEffect.of({
|
||||
content: null,
|
||||
document: null,
|
||||
anchor: null,
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
const requestId = ++this.currentRequestId;
|
||||
for await (const suggestion of immediateFetch(state)) {
|
||||
// Ignore stale requests.
|
||||
if (requestId !== this.currentRequestId) return;
|
||||
view.dispatch({
|
||||
effects: SuggestionUpdateEffect.of({
|
||||
content: suggestion.text,
|
||||
document: state.doc,
|
||||
anchor: state.selection.main.head,
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
return {
|
||||
fetcherPlugin,
|
||||
terminate: () => {
|
||||
activeRequest = false;
|
||||
clearTimeout(timeoutId);
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
/* --------------------------------------------------------------------------
|
||||
User Interaction (Acceptance Handler)
|
||||
---------------------------------------------------------------------------- */
|
||||
|
||||
// Returns a key binding that accepts the current suggestion.
|
||||
const createAcceptanceHandler = (
|
||||
terminateFetch: () => void,
|
||||
hotkey: string,
|
||||
getOptions: () => { delayMs?: number; splitStrategy?: SplitStrategy }
|
||||
) =>
|
||||
Prec.highest(
|
||||
keymap.of([
|
||||
{
|
||||
key: hotkey,
|
||||
run: (view: EditorView) => {
|
||||
const session = view.state.field(suggestionSessionState);
|
||||
if (!session.remainingText) return false;
|
||||
|
||||
// Always obtain the current split strategy from getOptions.
|
||||
const dynamicOptions = getOptions();
|
||||
const segmentationKey = dynamicOptions.splitStrategy ?? 'word';
|
||||
const { accepted, remaining } =
|
||||
TextSplitStrategies[segmentationKey](session.remainingText);
|
||||
|
||||
if (!accepted) return false;
|
||||
|
||||
// Insert the accepted suggestion text.
|
||||
view.dispatch({
|
||||
...insertCompletion(view.state, accepted),
|
||||
effects: SuggestionUpdateEffect.of({
|
||||
content: remaining || null,
|
||||
document: remaining ? session.baselineDocument : null,
|
||||
anchor: remaining ? session.anchorPosition! + accepted.length : null,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!remaining) terminateFetch();
|
||||
return true;
|
||||
},
|
||||
},
|
||||
])
|
||||
);
|
||||
|
||||
// Returns a key binding that manually triggers suggestions.
|
||||
const createTriggerHandler = (
|
||||
fetcherPlugin: ViewPlugin<any>,
|
||||
hotkey: string
|
||||
) =>
|
||||
Prec.highest(
|
||||
keymap.of([
|
||||
{
|
||||
key: hotkey,
|
||||
run: (view: EditorView) => {
|
||||
// Get the fetcher plugin instance and trigger suggestion.
|
||||
const pluginInstance = view.plugin(fetcherPlugin);
|
||||
if (pluginInstance && 'triggerSuggestion' in pluginInstance) {
|
||||
(pluginInstance as any).triggerSuggestion(view);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
},
|
||||
])
|
||||
);
|
||||
|
||||
// Helper to create a transaction that inserts completion text.
|
||||
const insertCompletion = (state: EditorState, text: string) => {
|
||||
const cursorPos = state.selection.main.head;
|
||||
return {
|
||||
...state.changeByRange(() => ({
|
||||
changes: { from: cursorPos, insert: text },
|
||||
range: EditorSelection.cursor(cursorPos + text.length),
|
||||
})),
|
||||
userEvent: 'completion.accept',
|
||||
};
|
||||
};
|
||||
|
||||
/* --------------------------------------------------------------------------
|
||||
Public API
|
||||
---------------------------------------------------------------------------- */
|
||||
|
||||
// The main extension function. It wires up session state management,
|
||||
// suggestion fetching, rendering, and user interaction.
|
||||
//
|
||||
// The split strategy is always obtained dynamically via `getOptions()`.
|
||||
/**
|
||||
* Creates an inline suggestions extension for CodeMirror.
|
||||
*
|
||||
* The main extension function that wires up session state management,
|
||||
* suggestion fetching, rendering, and user interaction.
|
||||
* The split strategy is always obtained dynamically via `getOptions()`.
|
||||
*/
|
||||
export function inlineSuggestions(config: InlineCompletionConfig) {
|
||||
const { fetchFunc, getOptions } = config;
|
||||
// Use the hotkey from the config if provided; otherwise, default to "Tab".
|
||||
const staticHotkey = config.acceptanceHotkey || 'Tab';
|
||||
// Determine if auto-trigger should be disabled when trigger hotkey is set.
|
||||
const autoTriggerEnabled = !config.triggerHotkey;
|
||||
const autoTriggerEnabled = !config.manualActivationKey;
|
||||
|
||||
// Normalize the fetch function to always return an async generator.
|
||||
const normalizeFetch = async function* (state: EditorState) {
|
||||
|
|
@ -502,8 +53,9 @@ export function inlineSuggestions(config: InlineCompletionConfig) {
|
|||
acceptanceHandler,
|
||||
];
|
||||
|
||||
if (config.triggerHotkey) {
|
||||
const triggerHandler = createTriggerHandler(fetcherPlugin, config.triggerHotkey);
|
||||
if (autoTriggerEnabled) {
|
||||
const hotkey = String(config.manualActivationKey);
|
||||
const triggerHandler = createTriggerHandler(fetcherPlugin, hotkey);
|
||||
extensions.push(triggerHandler);
|
||||
}
|
||||
|
||||
|
|
|
|||
45
src/extension/renderer.ts
Normal file
45
src/extension/renderer.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
// Suggestion rendering (visualization layer)
|
||||
|
||||
import { ViewPlugin, EditorView, ViewUpdate, Decoration, WidgetType } from '@codemirror/view';
|
||||
import { suggestionSessionState } from './session-state';
|
||||
|
||||
// Widget for rendering inline suggestion text.
|
||||
class SuggestionWidget extends WidgetType {
|
||||
static readonly CSS_CLASSES = ['cm-inline-prediction', 'inscribe-inline-prediction'];
|
||||
|
||||
constructor(private readonly content: string) {
|
||||
super();
|
||||
}
|
||||
|
||||
toDOM(): HTMLElement {
|
||||
const span = document.createElement('span');
|
||||
span.classList.add(...SuggestionWidget.CSS_CLASSES);
|
||||
span.textContent = this.content;
|
||||
return span;
|
||||
}
|
||||
}
|
||||
|
||||
// Plugin that renders inline suggestion decorations.
|
||||
export const suggestionRenderer = ViewPlugin.fromClass(
|
||||
class {
|
||||
decorations = Decoration.none;
|
||||
|
||||
update(update: ViewUpdate) {
|
||||
const session = update.state.field(suggestionSessionState);
|
||||
this.decorations = session.remainingText
|
||||
? this.createDecoration(update.view, session.remainingText)
|
||||
: Decoration.none;
|
||||
}
|
||||
|
||||
private createDecoration(view: EditorView, suggestionText: string) {
|
||||
const cursorPosition = view.state.selection.main.head;
|
||||
return Decoration.set([
|
||||
Decoration.widget({
|
||||
widget: new SuggestionWidget(suggestionText),
|
||||
side: 1,
|
||||
}).range(cursorPosition),
|
||||
]);
|
||||
}
|
||||
},
|
||||
{ decorations: (v) => v.decorations }
|
||||
);
|
||||
142
src/extension/session-state.ts
Normal file
142
src/extension/session-state.ts
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
// State management for suggestion sessions
|
||||
|
||||
import { StateEffect, Text, StateField, Transaction } from '@codemirror/state';
|
||||
import { SuggestionSession } from './types';
|
||||
|
||||
// Effect to update the suggestion session state.
|
||||
// A `null` content signals a reset.
|
||||
export const SuggestionUpdateEffect = StateEffect.define<{
|
||||
content: string | null;
|
||||
document: Text | null;
|
||||
anchor: number | null;
|
||||
}>();
|
||||
|
||||
// Creates a fresh, empty suggestion session.
|
||||
export function getResetSession(): SuggestionSession {
|
||||
return {
|
||||
fullText: null,
|
||||
remainingText: null,
|
||||
baselineDocument: null,
|
||||
anchorPosition: null,
|
||||
};
|
||||
}
|
||||
|
||||
// Update session state based on an incoming effect.
|
||||
export function updateSessionFromEffect(effect: {
|
||||
content: string | null;
|
||||
document: Text | null;
|
||||
anchor: number | null;
|
||||
}): SuggestionSession {
|
||||
return effect.content === null
|
||||
? getResetSession()
|
||||
: initializeSession(effect as {
|
||||
content: string;
|
||||
document: Text;
|
||||
anchor: number;
|
||||
});
|
||||
}
|
||||
|
||||
// Initializes a new suggestion session with provided effect data.
|
||||
function initializeSession(effect: {
|
||||
content: string;
|
||||
document: Text;
|
||||
anchor: number;
|
||||
}): SuggestionSession {
|
||||
return {
|
||||
fullText: effect.content,
|
||||
remainingText: effect.content,
|
||||
baselineDocument: effect.document,
|
||||
anchorPosition: effect.anchor,
|
||||
};
|
||||
}
|
||||
|
||||
// Adjust the suggestion session in response to document changes.
|
||||
export function updateSessionOnDocumentChange(
|
||||
session: SuggestionSession,
|
||||
transaction: Transaction
|
||||
): SuggestionSession {
|
||||
let insertedContent = '';
|
||||
let insertionAtAnchor = false;
|
||||
|
||||
// Iterate over document changes to detect an insertion at the suggestion's anchor.
|
||||
transaction.changes.iterChanges((fromA, toA, _fromB, _toB, inserted) => {
|
||||
if (fromA === session.anchorPosition && toA === fromA) {
|
||||
insertedContent = inserted.toString();
|
||||
insertionAtAnchor = true;
|
||||
}
|
||||
});
|
||||
|
||||
if (!insertionAtAnchor || !session.remainingText) {
|
||||
return invalidateSession(session);
|
||||
}
|
||||
|
||||
// Verify the inserted text matches the pending suggestion.
|
||||
if (session.remainingText.startsWith(insertedContent)) {
|
||||
return advanceSession(session, insertedContent.length);
|
||||
}
|
||||
|
||||
return invalidateSession(session);
|
||||
}
|
||||
|
||||
// Advance the session by consuming accepted text.
|
||||
function advanceSession(
|
||||
session: SuggestionSession,
|
||||
consumedLength: number
|
||||
): SuggestionSession {
|
||||
return {
|
||||
...session,
|
||||
remainingText:
|
||||
session.remainingText!.slice(consumedLength).length > 0
|
||||
? session.remainingText!.slice(consumedLength)
|
||||
: null,
|
||||
anchorPosition: session.anchorPosition! + consumedLength,
|
||||
};
|
||||
}
|
||||
|
||||
// Invalidate the session, effectively cancelling any pending suggestion.
|
||||
function invalidateSession(session: SuggestionSession): SuggestionSession {
|
||||
return {
|
||||
...session,
|
||||
remainingText: null,
|
||||
anchorPosition: null,
|
||||
};
|
||||
}
|
||||
|
||||
// Cancel the suggestion if the cursor has drifted away.
|
||||
export function updateSessionOnCursorDrift(
|
||||
session: SuggestionSession,
|
||||
transaction: Transaction
|
||||
): SuggestionSession {
|
||||
return transaction.state.selection.main.head !== session.anchorPosition
|
||||
? invalidateSession(session)
|
||||
: session;
|
||||
}
|
||||
|
||||
// The state field that holds the current suggestion session.
|
||||
export const suggestionSessionState = StateField.define<SuggestionSession>({
|
||||
create: () => getResetSession(),
|
||||
|
||||
update(session, transaction) {
|
||||
// Process explicit session update effects.
|
||||
const effect = transaction.effects.find((e) =>
|
||||
e.is(SuggestionUpdateEffect)
|
||||
);
|
||||
if (effect) return updateSessionFromEffect(effect.value);
|
||||
|
||||
// If the document has changed, adjust the session.
|
||||
if (
|
||||
transaction.docChanged &&
|
||||
session.remainingText &&
|
||||
session.anchorPosition !== null
|
||||
) {
|
||||
return updateSessionOnDocumentChange(session, transaction);
|
||||
}
|
||||
|
||||
// If there is an active suggestion but the cursor has moved, cancel it.
|
||||
if (session.remainingText !== null && session.anchorPosition !== null) {
|
||||
return updateSessionOnCursorDrift(session, transaction);
|
||||
}
|
||||
|
||||
return session;
|
||||
},
|
||||
});
|
||||
48
src/extension/text-strategies.ts
Normal file
48
src/extension/text-strategies.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
// Text segmentation strategies for inline completions
|
||||
|
||||
export type SplitResult = {
|
||||
accepted: string;
|
||||
remaining: string;
|
||||
};
|
||||
|
||||
// A set of text splitting functions used to determine how much of the
|
||||
// suggestion to accept when triggered.
|
||||
export const TextSplitStrategies = {
|
||||
// Word-level segmentation (space-delimited).
|
||||
// Accepts text until (and including) the first space.
|
||||
word: (text: string): SplitResult => {
|
||||
const nextSpace = text.indexOf(' ');
|
||||
return nextSpace === -1
|
||||
? { accepted: text, remaining: '' }
|
||||
: {
|
||||
accepted: text.slice(0, nextSpace + 1),
|
||||
remaining: text.slice(nextSpace + 1),
|
||||
};
|
||||
},
|
||||
|
||||
// Sentence-level segmentation (punctuation followed by whitespace).
|
||||
sentence: (text: string): SplitResult => {
|
||||
const match = text.match(/[.!?]\s+/);
|
||||
if (match && match.index !== undefined) {
|
||||
return {
|
||||
accepted: text.slice(0, match.index + 1),
|
||||
remaining: text.slice(match.index + 1),
|
||||
};
|
||||
}
|
||||
return { accepted: text, remaining: '' };
|
||||
},
|
||||
|
||||
// Paragraph-level segmentation (double newline).
|
||||
paragraph: (text: string): SplitResult => {
|
||||
const paragraphEnd = text.indexOf('\n\n');
|
||||
return paragraphEnd === -1
|
||||
? { accepted: text, remaining: '' }
|
||||
: {
|
||||
accepted: text.slice(0, paragraphEnd + 2),
|
||||
remaining: text.slice(paragraphEnd + 2),
|
||||
};
|
||||
},
|
||||
|
||||
// Atomic acceptance – consume the entire suggestion.
|
||||
full: (text: string): SplitResult => ({ accepted: text, remaining: '' }),
|
||||
} as const;
|
||||
37
src/extension/types.ts
Normal file
37
src/extension/types.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
// Type definitions for inline completions extension
|
||||
|
||||
import { EditorState } from '@codemirror/state';
|
||||
|
||||
// Supported segmentation strategies
|
||||
export type SplitStrategy = 'word' | 'sentence' | 'paragraph' | 'full';
|
||||
|
||||
// Inline suggestion structure
|
||||
export interface Suggestion {
|
||||
text: string;
|
||||
}
|
||||
|
||||
// Inline completion configuration
|
||||
export interface InlineCompletionConfig {
|
||||
fetchFunc: (
|
||||
state: EditorState
|
||||
) => AsyncGenerator<Suggestion> | Promise<Suggestion>;
|
||||
// Optional hotkey for accepting suggestions
|
||||
acceptanceHotkey?: string;
|
||||
// Optional hotkey for manually triggering suggestions
|
||||
manualActivationKey?: string;
|
||||
// Function that returns current options
|
||||
getOptions: () => InlineCompletionOptions;
|
||||
}
|
||||
|
||||
export interface InlineCompletionOptions {
|
||||
delayMs?: number;
|
||||
splitStrategy?: SplitStrategy;
|
||||
}
|
||||
|
||||
// Internal state for the current suggestion session
|
||||
export interface SuggestionSession {
|
||||
fullText: string | null;
|
||||
remainingText: string | null;
|
||||
baselineDocument: import('@codemirror/state').Text | null;
|
||||
anchorPosition: number | null;
|
||||
}
|
||||
|
|
@ -32,8 +32,8 @@ export default class Inscribe extends Plugin {
|
|||
const extension = inlineSuggestions({
|
||||
fetchFunc: () => this.completionService.fetchCompletion(),
|
||||
getOptions: () => this.profileService.getOptions(),
|
||||
acceptanceHotkey: this.settings.acceptanceHotkey,
|
||||
triggerHotkey: `Shift-Ctrl-Enter`
|
||||
acceptanceHotkey: this.settings.suggestionSettings.acceptanceHotkey,
|
||||
manualActivationKey: this.settings.suggestionSettings.manualActivationKey,
|
||||
});
|
||||
this.registerEditorExtension(extension);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ export interface Profile {
|
|||
provider: ProviderType,
|
||||
completionOptions: CompletionOptions,
|
||||
delayMs: number,
|
||||
// depracated, use SuggestionSettings instead
|
||||
splitStrategy: SplitStrategy
|
||||
}
|
||||
|
||||
|
|
@ -30,9 +31,15 @@ export type ProfileId = string;
|
|||
export type Profiles = Record<ProfileId, Profile>
|
||||
export type Path = string;
|
||||
export type PathConfig = { profile: ProfileId, enabled: boolean };
|
||||
export type SuggestionSettings = {
|
||||
acceptanceHotkey: string,
|
||||
manualActivationKey?: string,
|
||||
splitStrategy: SplitStrategy,
|
||||
}
|
||||
|
||||
export interface Settings {
|
||||
enabled: boolean,
|
||||
acceptanceHotkey: string,
|
||||
suggestionSettings: SuggestionSettings,
|
||||
// available providers
|
||||
providers: {
|
||||
ollama: OllamaSettings,
|
||||
|
|
@ -50,7 +57,10 @@ export const DEFAULT_PROFILE: ProfileId = "default";
|
|||
export const DEFAULT_PATH = "/";
|
||||
export const DEFAULT_SETTINGS: Settings = {
|
||||
enabled: false,
|
||||
acceptanceHotkey: "Tab",
|
||||
suggestionSettings: {
|
||||
acceptanceHotkey: "Tab",
|
||||
splitStrategy: "sentence",
|
||||
},
|
||||
providers: {
|
||||
openai: {
|
||||
integration: ProviderType.OPENAI,
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import { createProfile } from ".";
|
|||
|
||||
export default class InscribeSettingsTab extends PluginSettingTab {
|
||||
private generalSection: GeneralSection;
|
||||
private suggestionSettingsSection: SuggestionSettingsSection;
|
||||
private providersSection: ProvidersSection;
|
||||
private profilesSection: ProfilesSection;
|
||||
private pathConfigsSection: PathConfigsSection;
|
||||
|
|
@ -24,6 +25,10 @@ export default class InscribeSettingsTab extends PluginSettingTab {
|
|||
const generalContainer = document.createElement("div");
|
||||
this.containerEl.appendChild(generalContainer);
|
||||
|
||||
const suggestionSettingsContainer = document.createElement("div");
|
||||
suggestionSettingsContainer.addClass("inscribe-section");
|
||||
this.containerEl.appendChild(suggestionSettingsContainer);
|
||||
|
||||
const providersContainer = document.createElement("div");
|
||||
providersContainer.addClass("inscribe-section");
|
||||
this.containerEl.appendChild(providersContainer);
|
||||
|
|
@ -37,11 +42,13 @@ export default class InscribeSettingsTab extends PluginSettingTab {
|
|||
this.containerEl.appendChild(pathMappingsContainer);
|
||||
|
||||
this.generalSection = new GeneralSection(generalContainer, this.plugin, this.display.bind(this));
|
||||
this.suggestionSettingsSection = new SuggestionSettingsSection(suggestionSettingsContainer, this.plugin);
|
||||
this.providersSection = new ProvidersSection(providersContainer, this.app, this.plugin);
|
||||
this.pathConfigsSection = new PathConfigsSection(pathMappingsContainer, this.plugin);
|
||||
this.profilesSection = new ProfilesSection(profilesContainer, this.plugin, this.pathConfigsSection.render.bind(this.pathConfigsSection));
|
||||
|
||||
this.generalSection.render();
|
||||
this.suggestionSettingsSection.render();
|
||||
this.providersSection.render();
|
||||
this.profilesSection.render();
|
||||
this.pathConfigsSection.render();
|
||||
|
|
@ -75,21 +82,6 @@ class GeneralSection {
|
|||
});
|
||||
});
|
||||
|
||||
// Acceptance Hotkey
|
||||
new Setting(this.container)
|
||||
.setName("Acceptance hotkey")
|
||||
.setDesc("Hotkey to accept the current suggestion")
|
||||
.addText((text) => {
|
||||
text
|
||||
.setPlaceholder("e.g. Tab")
|
||||
.setValue(this.plugin.settings.acceptanceHotkey)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.acceptanceHotkey = value;
|
||||
await this.plugin.saveSettings();
|
||||
this.plugin.statusBarItem.render();
|
||||
});
|
||||
});
|
||||
|
||||
// Reset Settings
|
||||
new Setting(this.container)
|
||||
.setName("Reset settings")
|
||||
|
|
@ -110,6 +102,73 @@ class GeneralSection {
|
|||
}
|
||||
}
|
||||
|
||||
class SuggestionSettingsSection {
|
||||
private container: HTMLElement;
|
||||
private plugin: Inscribe;
|
||||
|
||||
constructor(container: HTMLElement, plugin: Inscribe) {
|
||||
this.container = container;
|
||||
this.plugin = plugin;
|
||||
}
|
||||
|
||||
async render(): Promise<void> {
|
||||
this.container.empty();
|
||||
|
||||
// Heading
|
||||
new Setting(this.container)
|
||||
.setHeading()
|
||||
.setName("Suggestions experience")
|
||||
.setDesc("Configure how completions are triggered and accepted");
|
||||
|
||||
// Acceptance Hotkey
|
||||
new Setting(this.container)
|
||||
.setName("Acceptance hotkey")
|
||||
.setDesc("Hotkey to accept the current suggestion")
|
||||
.addText((text) => {
|
||||
text
|
||||
.setPlaceholder("e.g. Tab")
|
||||
.setValue(this.plugin.settings.suggestionSettings.acceptanceHotkey)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.suggestionSettings.acceptanceHotkey = value;
|
||||
await this.plugin.saveSettings();
|
||||
this.plugin.statusBarItem.render();
|
||||
});
|
||||
});
|
||||
|
||||
// Manual Activation Key
|
||||
new Setting(this.container)
|
||||
.setName("Manual activation key")
|
||||
.setDesc("Key to manually trigger suggestions (if enabled)")
|
||||
.addText((text) => {
|
||||
text
|
||||
.setPlaceholder("e.g. Ctrl+Space")
|
||||
.setValue(this.plugin.settings.suggestionSettings.manualActivationKey || "")
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.suggestionSettings.manualActivationKey = value || undefined;
|
||||
await this.plugin.saveSettings();
|
||||
this.plugin.statusBarItem.render();
|
||||
});
|
||||
});
|
||||
|
||||
// Split Strategy
|
||||
new Setting(this.container)
|
||||
.setName("Completion strategy")
|
||||
.setDesc(`Choose how completions should be split and accepted`)
|
||||
.addDropdown((dropdown) => {
|
||||
dropdown
|
||||
.addOption("word", "Word by Word")
|
||||
.addOption("sentence", "Sentence by Sentence")
|
||||
.addOption("paragraph", "Paragraph by Paragraph")
|
||||
.addOption("full", "Full Completion")
|
||||
.setValue(this.plugin.settings.suggestionSettings.splitStrategy)
|
||||
.onChange(async (value: SplitStrategy) => {
|
||||
this.plugin.settings.suggestionSettings.splitStrategy = value;
|
||||
await this.plugin.saveSettings();
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class ProvidersSection {
|
||||
private container: HTMLElement;
|
||||
private plugin: Inscribe;
|
||||
|
|
@ -286,23 +345,6 @@ class ProfilesSection {
|
|||
});
|
||||
});
|
||||
|
||||
// Split Strategy
|
||||
new Setting(this.container)
|
||||
.setName("Completion strategy")
|
||||
.setDesc(`${profile.name} | Choose how completions should be split and accepted`)
|
||||
.addDropdown((dropdown) => {
|
||||
dropdown
|
||||
.addOption("word", "Word by Word")
|
||||
.addOption("sentence", "Sentence by Sentence")
|
||||
.addOption("paragraph", "Paragraph by Paragraph")
|
||||
.addOption("full", "Full Completion")
|
||||
.setValue(profile.splitStrategy)
|
||||
.onChange(async (value: SplitStrategy) => {
|
||||
profile.splitStrategy = value;
|
||||
await this.plugin.saveSettings();
|
||||
});
|
||||
});
|
||||
|
||||
// System Prompt
|
||||
new Setting(this.container)
|
||||
.setName("System prompt")
|
||||
|
|
|
|||
Loading…
Reference in a new issue