From 3c673e0f50a2acd5ea4e3b8f5cc0f50f9fd499ed Mon Sep 17 00:00:00 2001 From: Thiago Alves Date: Wed, 24 Jun 2026 01:31:56 -0400 Subject: [PATCH] feat: allow AI ghost text and STruC++ LSP autocomplete to coexist MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ST editor previously made AI inline completions and the LSP suggest widget mutually exclusive: when AI was on, the LSP auto-dropdown was suppressed (`quickSuggestions: false`, `inlineSuggest.suppressSuggestions: true`). The LSP completions are faster and deterministic (especially for variables and struct members), so hiding them behind the slower AI stream hurt the editing flow. Both completion sources are independent Monaco providers, so they can run at once. Now: - The LSP dropdown auto-opens in both modes (Enter / arrow-keys accept it). - The AI ghost text renders alongside it; Tab commits it — even while the suggest widget is open (Monaco's default reserves Tab for the dropdown). - While the dropdown is open with no ghost text, Tab is swallowed (reserved for AI) so it never accepts an LSP item. - When AI is off, Tab falls back to accepting the LSP dropdown as before. The Tab overrides are gated on a custom context key driven by `inlineCompletionsActive`, so toggling AI on/off takes effect without remounting the editor. Byte-identical companion change to openplc-web (shared editor surface). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../editor/monaco/ai-lsp-coexistence.ts | 62 +++++++++++++++++++ .../[workspace]/editor/monaco/index.tsx | 37 ++++++++--- 2 files changed, 90 insertions(+), 9 deletions(-) create mode 100644 src/frontend/components/_features/[workspace]/editor/monaco/ai-lsp-coexistence.ts diff --git a/src/frontend/components/_features/[workspace]/editor/monaco/ai-lsp-coexistence.ts b/src/frontend/components/_features/[workspace]/editor/monaco/ai-lsp-coexistence.ts new file mode 100644 index 000000000..31e79179f --- /dev/null +++ b/src/frontend/components/_features/[workspace]/editor/monaco/ai-lsp-coexistence.ts @@ -0,0 +1,62 @@ +import type * as monacoNs from 'monaco-editor' + +/** + * Custom Monaco context key that reflects whether AI inline completions and the + * STruC++ LSP suggest widget are allowed to coexist (i.e. AI is enabled, + * consented and inline completions are turned on). All Tab overrides below are + * gated on this key so they only take effect while coexistence is active — + * toggling AI off restores Monaco's default Tab=accept behaviour without needing + * to remount the editor. + */ +const COEXISTENCE_CONTEXT_KEY = 'openplcAiLspCoexistence' + +export type AiLspCoexistenceController = { + /** Enable/disable the coexistence Tab overrides at runtime. */ + setActive: (active: boolean) => void +} + +/** + * Wires Tab/Enter so the STruC++ LSP dropdown and the AI ghost text can be shown + * at the same time: + * + * - Enter (and arrow-key selection) accept the LSP suggest widget — Monaco's + * default, left untouched. + * - Tab commits the AI inline suggestion, even while the suggest widget is open + * (Monaco's default reserves Tab for the dropdown when both are visible). + * - While the suggest widget is open but no AI ghost text is present, Tab is + * swallowed (reserved for AI) instead of accepting the highlighted LSP item. + * + * The two overrides are registered once and gated on {@link COEXISTENCE_CONTEXT_KEY}; + * standalone keybindings added via `addCommand` are registered as overrides that + * take precedence over Monaco's built-in keybindings when their `when` clause + * matches. + */ +export function installAiLspCoexistenceKeybindings( + editor: monacoNs.editor.IStandaloneCodeEditor, + monaco: typeof monacoNs, +): AiLspCoexistenceController { + const active = editor.createContextKey(COEXISTENCE_CONTEXT_KEY, false) + + // Tab commits the AI inline suggestion even when the LSP dropdown is visible. + editor.addCommand( + monaco.KeyCode.Tab, + () => { + editor.trigger('openplc-ai-lsp', 'editor.action.inlineSuggest.commit', {}) + }, + `${COEXISTENCE_CONTEXT_KEY} && inlineSuggestionVisible && !editorReadonly`, + ) + + // While the LSP dropdown is open without any AI ghost text, Tab is reserved for + // AI: swallow it so it never accepts the highlighted LSP item (Enter does that). + editor.addCommand( + monaco.KeyCode.Tab, + () => { + /* no-op: Tab is reserved for AI completions while coexistence is active */ + }, + `${COEXISTENCE_CONTEXT_KEY} && suggestWidgetVisible && !inlineSuggestionVisible`, + ) + + return { + setActive: (value: boolean) => active.set(value), + } +} diff --git a/src/frontend/components/_features/[workspace]/editor/monaco/index.tsx b/src/frontend/components/_features/[workspace]/editor/monaco/index.tsx index e034e7f18..b8b939be7 100644 --- a/src/frontend/components/_features/[workspace]/editor/monaco/index.tsx +++ b/src/frontend/components/_features/[workspace]/editor/monaco/index.tsx @@ -17,6 +17,7 @@ import { parseHybridPouFromString, parseTextualPouFromString } from '../../../.. import { Modal, ModalContent, ModalTitle } from '../../../../_molecules/modal' import { toast } from '../../../[app]/toast/use-toast' import { renderDiffReview } from './ai-diff-review' +import { type AiLspCoexistenceController, installAiLspCoexistenceKeybindings } from './ai-lsp-coexistence' import { arduinoApiCompletion, cppSignatureHelp, @@ -127,6 +128,7 @@ const MonacoEditor = (props: monacoEditorProps): ReturnType(null) const monacoRef = useRef(null) const focusDisposables = useRef<{ onFocus?: monaco.IDisposable; onBlur?: monaco.IDisposable }>({}) + const coexistenceRef = useRef(null) const [editorMounted, setEditorMounted] = useState(false) const [modelVersion, setModelVersion] = useState(0) const isSyncingModelRef = useRef(false) @@ -986,6 +988,12 @@ const MonacoEditor = (props: monacoEditorProps): ReturnType { const isMac = navigator.platform.toUpperCase().includes('MAC') @@ -1220,13 +1228,13 @@ void loop() // Editor options // ----------------------------------------------------------------------- - // Inline AI completions take over the suggest widget only while they are - // actually active (same gate as the provider registration above). When the - // user turns inline completions off, fall back to Monaco's normal quick - // suggestions (the auto-dropdown). Ctrl+Space still triggers the suggest - // widget manually in both modes — `quickSuggestions` only governs the - // automatic popup, and `suppressSuggestions` only suppresses the auto popup - // while an inline suggestion is showing. + // AI inline completions and the STruC++ LSP suggest widget COEXIST: the + // LSP dropdown still auto-opens (fast, deterministic, great for variables and + // struct members) while the AI ghost text renders alongside it. Acceptance is + // split by key — Enter/arrows accept the LSP dropdown, Tab commits the AI + // suggestion (see `installAiLspCoexistenceKeybindings`). `suppressSuggestions` + // is therefore false so the dropdown is NOT hidden while ghost text shows. + // Ctrl+Space still triggers the suggest widget manually in both modes. const inlineCompletionsActive = capabilities.hasAIAssistant && aiState.isEnabled && @@ -1249,7 +1257,9 @@ void loop() tabSize: 4, insertSpaces: true, detectIndentation: false, - quickSuggestions: inlineCompletionsActive ? false : undefined, + // Let the LSP dropdown auto-open in both modes — even with AI on, we want + // the fast LSP completions visible (the user accepts them with Enter/arrows). + quickSuggestions: undefined, // Pinned for cross-platform consistency with the variables-code-editor. // Monaco's default is platform-dependent (12 on macOS, 14 elsewhere) — // without this both surfaces would mismatch on Linux/Windows even @@ -1274,11 +1284,20 @@ void loop() ...(inlineCompletionsActive && { inlineSuggest: { enabled: true, - suppressSuggestions: true, + // Keep the LSP dropdown visible alongside the AI ghost text instead of + // suppressing it — coexistence is the whole point here. + suppressSuggestions: false, }, }), } + // Keep the coexistence Tab overrides in sync with AI state so toggling AI on/off + // takes effect without remounting the editor. `editorInstanceId` re-asserts it + // after a remount (the mount handler also sets it, this is belt-and-braces). + useEffect(() => { + coexistenceRef.current?.setActive(inlineCompletionsActive) + }, [inlineCompletionsActive, editorInstanceId]) + // ----------------------------------------------------------------------- // Drag-and-drop // -----------------------------------------------------------------------