Skip to content

Commit a391bd1

Browse files
authored
Add inline completion plugin (#424)
1 parent 8c7765e commit a391bd1

File tree

5 files changed

+1057
-11
lines changed

5 files changed

+1057
-11
lines changed
Lines changed: 268 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,268 @@
1+
/*
2+
* Copyright (c) 2021-2023 Datalayer, Inc.
3+
*
4+
* MIT License
5+
*/
6+
7+
/**
8+
* Lexical DecoratorNode for rendering LLM-powered inline completion ghost text.
9+
* Uses NodeTransform to persist across JupyterInputNode updates from syntax highlighting.
10+
*
11+
* @module nodes/InlineCompletionNode
12+
*
13+
* @remarks
14+
* This node is managed by LexicalInlineCompletionPlugin which:
15+
* - Inserts the node when completions are received
16+
* - Re-adds it via NodeTransform when JupyterInputOutputPlugin recreates the tree
17+
* - Removes it when Tab (accept) or Escape (dismiss) is pressed
18+
*
19+
* @example
20+
* ```typescript
21+
* const completionNode = $createInlineCompletionNode('suggested code');
22+
* jupyterInputNode.append(completionNode);
23+
* ```
24+
*/
25+
26+
import type {
27+
DOMConversionMap,
28+
DOMExportOutput,
29+
EditorConfig,
30+
LexicalNode,
31+
NodeKey,
32+
SerializedLexicalNode,
33+
Spread,
34+
} from 'lexical';
35+
36+
import { DecoratorNode } from 'lexical';
37+
import * as React from 'react';
38+
39+
/**
40+
* Props for InlineCompletionComponent
41+
*/
42+
export interface InlineCompletionNodeProps {
43+
/** The completion text to display as ghost text */
44+
completionText: string;
45+
}
46+
47+
/**
48+
* Serialized representation of InlineCompletionNode
49+
*/
50+
export type SerializedInlineCompletionNode = Spread<
51+
{
52+
completionText: string;
53+
},
54+
SerializedLexicalNode
55+
>;
56+
57+
/**
58+
* Lexical DecoratorNode that renders inline completion ghost text.
59+
* Styled with low opacity and VS Code theme colors to appear as suggestions.
60+
*/
61+
export class InlineCompletionNode extends DecoratorNode<React.ReactElement> {
62+
__completionText: string;
63+
64+
/**
65+
* Returns the node type identifier.
66+
* @returns Node type string
67+
*/
68+
static getType(): string {
69+
return 'inline-completion';
70+
}
71+
72+
/**
73+
* Clones an existing InlineCompletionNode.
74+
* @param node - Node to clone
75+
* @returns New node instance
76+
*/
77+
static clone(node: InlineCompletionNode): InlineCompletionNode {
78+
return new InlineCompletionNode(node.__completionText, node.__key);
79+
}
80+
81+
/**
82+
* Creates a new InlineCompletionNode.
83+
* @param completionText - The ghost text to display
84+
* @param key - Optional Lexical node key
85+
*/
86+
constructor(completionText: string, key?: NodeKey) {
87+
super(key);
88+
this.__completionText = completionText;
89+
}
90+
91+
/**
92+
* Creates the DOM element for this node.
93+
* Styled as ghost text with low opacity and pointer-events disabled.
94+
* @param _config - Editor configuration (unused)
95+
* @returns Configured span element
96+
*/
97+
createDOM(_config: EditorConfig): HTMLElement {
98+
const dom = document.createElement('span');
99+
dom.className = 'inline-completion-ghost';
100+
dom.style.cssText = `
101+
opacity: 0.5;
102+
color: var(--vscode-editorSuggestWidget-foreground, #999);
103+
pointer-events: none;
104+
user-select: none;
105+
white-space: pre;
106+
`;
107+
return dom;
108+
}
109+
110+
/**
111+
* Indicates whether DOM element needs updating.
112+
* @returns Always false - DOM is static
113+
*/
114+
updateDOM(): false {
115+
return false;
116+
}
117+
118+
/**
119+
* Deserializes node from JSON.
120+
* @param serializedNode - Serialized node data
121+
* @returns New InlineCompletionNode instance
122+
*/
123+
static importJSON(
124+
serializedNode: SerializedInlineCompletionNode,
125+
): InlineCompletionNode {
126+
return $createInlineCompletionNode(serializedNode.completionText);
127+
}
128+
129+
/**
130+
* Serializes node to JSON.
131+
* @returns Serialized node representation
132+
*/
133+
exportJSON(): SerializedInlineCompletionNode {
134+
// Don't serialize completion nodes - they're ephemeral ghost text
135+
// Return minimal structure to satisfy type system
136+
return {
137+
completionText: '',
138+
type: 'inline-completion',
139+
version: 1,
140+
};
141+
}
142+
143+
/**
144+
* Disables DOM import for this node type.
145+
* @returns null - no DOM import support
146+
*/
147+
static importDOM(): DOMConversionMap | null {
148+
return null;
149+
}
150+
151+
/**
152+
* Disables DOM export for this node type.
153+
* @returns Empty export output
154+
*/
155+
exportDOM(): DOMExportOutput {
156+
return { element: null };
157+
}
158+
159+
/**
160+
* Gets the completion text.
161+
* @returns The ghost text string
162+
*/
163+
getCompletionText(): string {
164+
return this.__completionText;
165+
}
166+
167+
/**
168+
* Updates the completion text.
169+
* @param text - New completion text
170+
*/
171+
setCompletionText(text: string): void {
172+
const writable = this.getWritable();
173+
writable.__completionText = text;
174+
}
175+
176+
/**
177+
* Renders the React component for this decorator node.
178+
* @returns React element displaying ghost text
179+
*/
180+
decorate(): React.ReactElement {
181+
console.warn(
182+
'[InlineCompletionNode] 🎨 decorate() called with text:',
183+
this.__completionText.substring(0, 50),
184+
);
185+
return <InlineCompletionComponent completionText={this.__completionText} />;
186+
}
187+
188+
/**
189+
* Indicates this is an inline node.
190+
* @returns Always true
191+
*/
192+
isInline(): boolean {
193+
return true;
194+
}
195+
196+
/**
197+
* Prevents keyboard selection of this node.
198+
* @returns Always false - ghost text should not be selectable
199+
*/
200+
isKeyboardSelectable(): boolean {
201+
return false;
202+
}
203+
}
204+
205+
/**
206+
* Factory function to create an InlineCompletionNode.
207+
* @param completionText - The ghost text to display
208+
* @returns New InlineCompletionNode instance
209+
*
210+
* @example
211+
* ```typescript
212+
* const node = $createInlineCompletionNode('def fib(n):\n return n');
213+
* jupyterInputNode.append(node);
214+
* ```
215+
*/
216+
export function $createInlineCompletionNode(
217+
completionText: string,
218+
): InlineCompletionNode {
219+
return new InlineCompletionNode(completionText);
220+
}
221+
222+
/**
223+
* Type guard to check if a node is an InlineCompletionNode.
224+
* @param node - Node to check
225+
* @returns True if node is InlineCompletionNode
226+
*
227+
* @example
228+
* ```typescript
229+
* if ($isInlineCompletionNode(node)) {
230+
* const text = node.getCompletionText();
231+
* }
232+
* ```
233+
*/
234+
export function $isInlineCompletionNode(
235+
node: LexicalNode | null | undefined,
236+
): node is InlineCompletionNode {
237+
return node instanceof InlineCompletionNode;
238+
}
239+
240+
/**
241+
* React component that renders inline completion ghost text.
242+
* Styled with low opacity to appear as a suggestion rather than actual code.
243+
*
244+
* @param props - Component props
245+
* @returns Styled span with ghost text
246+
*/
247+
function InlineCompletionComponent({
248+
completionText,
249+
}: InlineCompletionNodeProps): React.ReactElement {
250+
console.warn(
251+
'[InlineCompletionComponent] 🖼️ Rendering with text:',
252+
completionText.substring(0, 50),
253+
);
254+
return (
255+
<span
256+
className="inline-completion-text"
257+
style={{
258+
opacity: 0.5,
259+
color: 'var(--vscode-editorSuggestWidget-foreground, #999)',
260+
pointerEvents: 'none',
261+
userSelect: 'none',
262+
whiteSpace: 'pre',
263+
}}
264+
>
265+
{completionText}
266+
</span>
267+
);
268+
}

packages/lexical/src/nodes/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,3 +13,4 @@ export * from './JupyterInputNode';
1313
export * from './JupyterOutputNode';
1414
export * from './JupyterCellNode';
1515
export * from './YouTubeNode';
16+
export * from './InlineCompletionNode';

packages/lexical/src/plugins/JupyterInputOutputPlugin.tsx

Lines changed: 65 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -465,20 +465,74 @@ export const JupyterInputOutputPlugin = (
465465
}
466466

467467
if (jupyterInputNode) {
468-
// Prevent default browser behavior only when we're handling the event
469468
event.preventDefault();
470469
event.stopPropagation();
471470

472-
// Select all content within the Jupyter input node
473-
const rangeSelection = $createRangeSelection();
474-
rangeSelection.anchor.set(jupyterInputNode.getKey(), 0, 'element');
475-
rangeSelection.focus.set(
476-
jupyterInputNode.getKey(),
477-
jupyterInputNode.getChildrenSize(),
478-
'element',
479-
);
480-
$setSelection(rangeSelection);
481-
return true; // Prevent default select-all behavior
471+
// Check if entire cell is already selected by looking at the selection range
472+
const isCollapsed = selection.isCollapsed();
473+
const cellKey = jupyterInputNode.getKey();
474+
const cellSize = jupyterInputNode.getChildrenSize();
475+
476+
// Selection is considered "entire cell" if it spans from offset 0 to childrenSize
477+
// We need to check if selection covers the full range, regardless of anchor/focus order
478+
let selectionCoversEntireCell = false;
479+
480+
if (!isCollapsed) {
481+
// Get the anchor and focus nodes
482+
const anchorNode = selection.anchor.getNode();
483+
const focusNode = selection.focus.getNode();
484+
485+
// Check if both anchor and focus are within or at the jupyter input node
486+
const anchorInCell =
487+
anchorNode.getKey() === cellKey ||
488+
anchorNode.getParent()?.getKey() === cellKey;
489+
const focusInCell =
490+
focusNode.getKey() === cellKey ||
491+
focusNode.getParent()?.getKey() === cellKey;
492+
493+
if (anchorInCell && focusInCell) {
494+
// Get text content to check if entire cell is selected
495+
const selectedText = selection.getTextContent();
496+
const cellText = jupyterInputNode.getTextContent();
497+
selectionCoversEntireCell = selectedText === cellText;
498+
}
499+
}
500+
501+
console.warn('[SELECT_ALL] Check:', {
502+
isCollapsed,
503+
selectionCoversEntireCell,
504+
selectedText: selection.getTextContent().substring(0, 50),
505+
cellText: jupyterInputNode.getTextContent().substring(0, 50),
506+
});
507+
508+
if (selectionCoversEntireCell) {
509+
// Second Cmd+A: Select ENTIRE document
510+
console.warn(
511+
'[SELECT_ALL] Second press - selecting entire document',
512+
);
513+
const root = $getRoot();
514+
const rangeSelection = $createRangeSelection();
515+
rangeSelection.anchor.set(root.getKey(), 0, 'element');
516+
rangeSelection.focus.set(
517+
root.getKey(),
518+
root.getChildrenSize(),
519+
'element',
520+
);
521+
$setSelection(rangeSelection);
522+
return true;
523+
} else {
524+
// First Cmd+A: Select all content within current cell
525+
console.warn('[SELECT_ALL] First press - selecting current cell');
526+
const rangeSelection = $createRangeSelection();
527+
rangeSelection.anchor.set(jupyterInputNode.getKey(), 0, 'element');
528+
rangeSelection.focus.set(
529+
jupyterInputNode.getKey(),
530+
cellSize,
531+
'element',
532+
);
533+
$setSelection(rangeSelection);
534+
return true;
535+
}
482536
}
483537

484538
return false; // Allow default select-all if not in Jupyter cell

0 commit comments

Comments
 (0)