This is an automated email from the ASF dual-hosted git repository.

voidmatcha pushed a commit to branch pr5339-rebase
in repository https://gitbox.apache.org/repos/asf/zeppelin.git

commit 47fe67719e3b9b951d991c3daf0cb9ad7920a690
Author: YONGJAE LEE <[email protected]>
AuthorDate: Fri Jul 24 01:52:40 2026 +0900

    [ZEPPELIN-6557] New UI: history-based inline (ghost-text) completion
---
 .../paragraph/code-editor/code-editor.component.ts |  16 +-
 .../src/app/services/inline-completion.service.ts  | 203 +++++++++++++++++++++
 .../src/app/services/public-api.ts                 |   1 +
 3 files changed, 218 insertions(+), 2 deletions(-)

diff --git 
a/zeppelin-web-angular/src/app/pages/workspace/notebook/paragraph/code-editor/code-editor.component.ts
 
b/zeppelin-web-angular/src/app/pages/workspace/notebook/paragraph/code-editor/code-editor.component.ts
index 55623db67e..d91d5f43ab 100644
--- 
a/zeppelin-web-angular/src/app/pages/workspace/notebook/paragraph/code-editor/code-editor.component.ts
+++ 
b/zeppelin-web-angular/src/app/pages/workspace/notebook/paragraph/code-editor/code-editor.component.ts
@@ -26,7 +26,7 @@ import {
 import { editor as MonacoEditor, IDisposable, IPosition, KeyCode } from 
'monaco-editor';
 
 import { InterpreterBindingItem } from '@zeppelin/sdk';
-import { CompletionService, MessageService } from '@zeppelin/services';
+import { CompletionService, InlineCompletionService, MessageService } from 
'@zeppelin/services';
 
 import { MonacoKeyboardEventHandler, ParagraphActions, 
ParagraphActionToHandlerName } from '@zeppelin/key-binding';
 import { pt2px } from '@zeppelin/utility';
@@ -217,6 +217,7 @@ export class NotebookParagraphCodeEditorComponent
     this.initEditorListener(this.editor);
     this.initEditorFocus();
     this.initCompletionService(this.editor);
+    this.initInlineCompletionService(this.editor);
     this.setEditorValue(this.editor);
     // A term requested before Monaco finished loading was only stored, not 
applied yet.
     this.highlightMatches(this.searchTerm);
@@ -283,6 +284,14 @@ export class NotebookParagraphCodeEditorComponent
     this.completionService.registerAsCompletionReceiver(model, 
this.paragraphControl.pid);
   }
 
+  initInlineCompletionService(editor: IStandaloneCodeEditor): void {
+    const model = editor.getModel();
+    if (!model) {
+      return;
+    }
+    this.inlineCompletionService.register(model, this.paragraphControl.pid);
+  }
+
   initEditorFocus() {
     if (this.focus && this.editor) {
       this.editor.focus();
@@ -302,6 +311,7 @@ export class NotebookParagraphCodeEditorComponent
       contextmenu: false,
       matchBrackets: 'always',
       wordWrap: 'on',
+      inlineSuggest: { enabled: true },
       scrollbar: {
         handleMouseWheel: false,
         alwaysConsumeMouseWheel: false
@@ -394,7 +404,8 @@ export class NotebookParagraphCodeEditorComponent
     private cdr: ChangeDetectorRef,
     private ngZone: NgZone,
     private messageService: MessageService,
-    private completionService: CompletionService
+    private completionService: CompletionService,
+    private inlineCompletionService: InlineCompletionService
   ) {}
 
   ngOnChanges(changes: SimpleChanges): void {
@@ -425,6 +436,7 @@ export class NotebookParagraphCodeEditorComponent
     const model = this.editor?.getModel();
     if (model) {
       this.completionService.unregister(model);
+      this.inlineCompletionService.unregister(model);
     }
     this.monacoDisposables.forEach(d => d.dispose());
   }
diff --git a/zeppelin-web-angular/src/app/services/inline-completion.service.ts 
b/zeppelin-web-angular/src/app/services/inline-completion.service.ts
new file mode 100644
index 0000000000..62f92b4ded
--- /dev/null
+++ b/zeppelin-web-angular/src/app/services/inline-completion.service.ts
@@ -0,0 +1,203 @@
+/*
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { Injectable } from '@angular/core';
+import { editor, IDisposable, IRange, languages, Position } from 
'monaco-editor';
+
+const MIN_PREFIX_LENGTH = 3;
+const LOCAL_WINDOW_LINES = 400;
+const MAX_CROSS_SCAN_LINES = 4000;
+
+interface Registration {
+  // Unused by the history tier; kept as the paragraph id the future LLM tier 
sends to the server.
+  pid: string;
+  sub: IDisposable;
+}
+
+/**
+ * Ghost-text (inline) completions for the notebook editor.
+ *
+ * `suggest()` resolves a suggestion through tiers:
+ * a future server-backed LLM tier falls back to the history tier below,
+ * which repeats a line already written in the note.
+ *
+ * Opt-in via `?aiInlineComplete=true`; off by default.
+ */
+@Injectable({ providedIn: 'root' })
+export class InlineCompletionService {
+  private readonly languagesToRegister = ['python', 'scala'];
+  // Plain Map (not WeakMap) so the history tier can scan every open paragraph.
+  // Models self-remove on dispose, so they are never pinned in memory.
+  private readonly models = new Map<editor.ITextModel, Registration>();
+  private bound = false;
+
+  register(model: editor.ITextModel, pid: string): void {
+    if (!this.isEnabled() || this.models.has(model)) {
+      return;
+    }
+    if (!this.bound) {
+      this.bindProvider();
+      this.bound = true;
+    }
+    const sub = model.onWillDispose(() => this.unregister(model));
+    this.models.set(model, { pid, sub });
+  }
+
+  unregister(model: editor.ITextModel): void {
+    const entry = this.models.get(model);
+    if (entry) {
+      entry.sub.dispose();
+      this.models.delete(model);
+    }
+  }
+
+  private isEnabled(): boolean {
+    try {
+      return new 
URLSearchParams(window.location.search).get('aiInlineComplete') === 'true';
+    } catch {
+      return false;
+    }
+  }
+
+  // Registered once for the app lifetime; the provider disposable is 
intentionally dropped.
+  private bindProvider(): void {
+    this.languagesToRegister.forEach(language => {
+      languages.registerInlineCompletionsProvider(language, {
+        provideInlineCompletions: (model: editor.ITextModel, position: 
Position): languages.InlineCompletions => {
+          // The provider is global per language; only enabled, registered 
models get suggestions.
+          if (!this.isEnabled() || !this.models.has(model)) {
+            return { items: [] };
+          }
+          const insertText = this.suggest(model, position);
+          if (!insertText) {
+            return { items: [] };
+          }
+          const range: IRange = {
+            startLineNumber: position.lineNumber,
+            startColumn: position.column,
+            endLineNumber: position.lineNumber,
+            endColumn: position.column
+          };
+          return { items: [{ insertText, range }] };
+        },
+        freeInlineCompletions: (): void => {}
+      });
+    });
+  }
+
+  /** Seam for tiers. The LLM tier slots in above history in a follow-up; 
today only history runs. */
+  private suggest(model: editor.ITextModel, position: Position): string | null 
{
+    return this.historySuggestion(model, position);
+  }
+
+  /**
+   * If a line elsewhere starts with the current line's text before the cursor,
+   * offer the rest as ghost text:
+   * the current cell first (nearest match), then other paragraphs.
+   * Scans are line-budgeted so the per-keystroke cost stays bounded. Pure 
client-side.
+   */
+  private historySuggestion(model: editor.ITextModel, position: Position): 
string | null {
+    // Only complete at end of line; otherwise the remainder would corrupt the 
existing suffix.
+    const afterCursor = 
model.getLineContent(position.lineNumber).slice(position.column - 1);
+    if (afterCursor.trim().length > 0) {
+      return null;
+    }
+
+    const linePrefix = model.getValueInRange({
+      startLineNumber: position.lineNumber,
+      startColumn: 1,
+      endLineNumber: position.lineNumber,
+      endColumn: position.column
+    });
+    if (linePrefix.trim().length < MIN_PREFIX_LENGTH) {
+      return null;
+    }
+
+    // Current cell: a window around the cursor, nearest match wins.
+    const cursorIndex = position.lineNumber - 1;
+    const local = this.matchInModel(
+      model,
+      linePrefix,
+      cursorIndex,
+      Math.max(0, cursorIndex - LOCAL_WINDOW_LINES),
+      cursorIndex + LOCAL_WINDOW_LINES + 1
+    );
+    if (local !== null) {
+      return local;
+    }
+
+    // Other paragraphs, same language, sharing one line budget so total 
cross-paragraph work per
+    // keystroke stays bounded (a huge paragraph consumes the budget rather 
than blowing it up).
+    let budget = MAX_CROSS_SCAN_LINES;
+    for (const other of this.models.keys()) {
+      if (other === model) {
+        continue;
+      }
+      if (other.isDisposed()) {
+        this.unregister(other);
+        continue;
+      }
+      // Language checked here, not at register time, since a paragraph's 
language can change.
+      if (other.getLanguageId() !== model.getLanguageId()) {
+        continue;
+      }
+      if (budget <= 0) {
+        break;
+      }
+      const remainder = this.matchInModel(other, linePrefix, -1, 0, budget);
+      budget -= Math.min(other.getLineCount(), budget);
+      if (remainder !== null) {
+        return remainder;
+      }
+    }
+    return null;
+  }
+
+  /**
+   * Remainder of the line nearest `skipIndex` starting with `linePrefix`, 
within `[startLine, endLine)`.
+   * Ties go to the earlier line; `skipIndex = -1` returns the topmost match.
+   * Trailing whitespace is stripped and whitespace-only matches skipped.
+   */
+  private matchInModel(
+    model: editor.ITextModel,
+    linePrefix: string,
+    skipIndex: number,
+    startLine: number,
+    endLine: number
+  ): string | null {
+    const limit = Math.min(endLine, model.getLineCount());
+    let remainder: string | null = null;
+    let bestDistance = Infinity;
+    for (let i = Math.max(0, startLine); i < limit; i++) {
+      if (i === skipIndex) {
+        continue;
+      }
+      const line = model.getLineContent(i + 1);
+      if (!line.startsWith(linePrefix)) {
+        continue;
+      }
+      // trimEnd() is ES2019+ and this tsconfig's lib is es2018, so use a 
regex.
+      const candidate = line.slice(linePrefix.length).replace(/\s+$/, '');
+      if (candidate.length > 0) {
+        if (skipIndex < 0) {
+          return candidate;
+        }
+        const distance = Math.abs(i - skipIndex);
+        if (distance < bestDistance) {
+          bestDistance = distance;
+          remainder = candidate;
+        }
+      }
+    }
+    return remainder;
+  }
+}
diff --git a/zeppelin-web-angular/src/app/services/public-api.ts 
b/zeppelin-web-angular/src/app/services/public-api.ts
index 7a80b4d351..19bf9b5c8d 100644
--- a/zeppelin-web-angular/src/app/services/public-api.ts
+++ b/zeppelin-web-angular/src/app/services/public-api.ts
@@ -18,6 +18,7 @@ export * from './completion.service';
 export * from './configuration.service';
 export * from './credential.service';
 export * from './helium.service';
+export * from './inline-completion.service';
 export * from './interpreter.service';
 export * from './job-manager.service';
 export * from './message.service';

Reply via email to