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 // -----------------------------------------------------------------------