|
| 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 | +} |
0 commit comments