From a7e3e067a93d8ba7d8ebe7e5d25675fe2abfb877 Mon Sep 17 00:00:00 2001 From: Yarin Barnes Date: Sat, 14 Mar 2026 20:38:13 +0200 Subject: [PATCH 1/4] fix(editor): keep Ctrl+Space autocomplete open while typing and support mid-word trigger Convert ctrlSpaceOpenRef from a ref to state so toggling it triggers re-renders and recomputes filtered suggestions. When the user explicitly opens autocomplete via Ctrl+Space, position-based filtering is bypassed so all suggestions are available for fuzzy matching against the word at the cursor. The completer stays open while typing and only closes on space, newline, Escape, or accepting a completion. --- .../frontend/components/ui/editor/Editor.tsx | 34 ++++++++++++------- 1 file changed, 22 insertions(+), 12 deletions(-) diff --git a/apps/cruncher/src/processes/frontend/components/ui/editor/Editor.tsx b/apps/cruncher/src/processes/frontend/components/ui/editor/Editor.tsx index 48dc44b..f525320 100644 --- a/apps/cruncher/src/processes/frontend/components/ui/editor/Editor.tsx +++ b/apps/cruncher/src/processes/frontend/components/ui/editor/Editor.tsx @@ -262,7 +262,7 @@ export const Editor = React.forwardRef( >(undefined); const [hasInteractedWithMenu, setHasInteractedWithMenu] = useState(false); - const ctrlSpaceOpenRef = useRef(false); + const [ctrlSpaceOpen, setCtrlSpaceOpen] = useState(false); const resolvedHeaderShortcuts = useResolvedShortcuts(headerShortcuts); const resolvedSearcherShortcuts = useResolvedShortcuts(searcherShortcuts); @@ -282,10 +282,14 @@ export const Editor = React.forwardRef( const filteredSuggestions = useMemo(() => { const results = new Map(); for (const suggestion of suggestions) { - // filter suggestions based on cursor position - if (cursorPosition < suggestion.fromPosition) continue; - if (suggestion.toPosition && cursorPosition > suggestion.toPosition) - continue; + // When user explicitly triggered autocomplete (Ctrl+Space), skip + // position-based filtering so all suggestions are available for + // fuzzy matching against the word at the cursor. + if (!ctrlSpaceOpen) { + if (cursorPosition < suggestion.fromPosition) continue; + if (suggestion.toPosition && cursorPosition > suggestion.toPosition) + continue; + } const key = `${suggestion.type}:${suggestion.value}`; if (!results.has(key)) results.set(key, suggestion); @@ -314,7 +318,7 @@ export const Editor = React.forwardRef( return b.score - a.score; }) .map((entry) => entry.suggestion); - }, [suggestions, cursorPosition, writtenWord]); + }, [suggestions, cursorPosition, writtenWord, ctrlSpaceOpen]); const acceptCompletion = () => { let startPos = cursorPosition - writtenWord.length; @@ -339,7 +343,7 @@ export const Editor = React.forwardRef( } }, 0); - ctrlSpaceOpenRef.current = false; + setCtrlSpaceOpen(false); setIsCompleterOpen(false); setHasInteractedWithMenu(false); }; @@ -587,13 +591,13 @@ export const Editor = React.forwardRef( // TODO move it to shortcuts system // if key is esc - close completer if (e.key === "Escape") { - ctrlSpaceOpenRef.current = false; + setCtrlSpaceOpen(false); setIsCompleterOpen(false); setHasInteractedWithMenu(false); } if (resolvedHeaderShortcuts.isPressed(e, "trigger-autocomplete")) { e.preventDefault(); - ctrlSpaceOpenRef.current = true; + setCtrlSpaceOpen(true); setIsCompleterOpen(true); } @@ -677,10 +681,16 @@ export const Editor = React.forwardRef( dismissAutoHint(); onChange(e.target.value); setCursorPosition(e.currentTarget.selectionStart); - if (!isCharAdded && !hasFreshSuggestions) { - ctrlSpaceOpenRef.current = false; + + if (ctrlSpaceOpen) { + // Keep autocomplete open while typing; close on space/newline + if (char === "\n" || (char === " " && !hasFreshSuggestions)) { + setCtrlSpaceOpen(false); + setIsCompleterOpen(false); + } + } else { + setIsCompleterOpen(isCharAdded || hasFreshSuggestions); } - setIsCompleterOpen(isCharAdded || hasFreshSuggestions); setHoveredCompletionItem(undefined); setHasInteractedWithMenu(false); From f32c97b13d7ab7c5c0cecc03e26c22cfab697783 Mon Sep 17 00:00:00 2001 From: Yarin Barnes Date: Sat, 14 Mar 2026 20:40:06 +0200 Subject: [PATCH 2/4] chore: add changeset for ctrl-space autocomplete fix --- .changeset/fix-ctrl-space-autocomplete.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/fix-ctrl-space-autocomplete.md diff --git a/.changeset/fix-ctrl-space-autocomplete.md b/.changeset/fix-ctrl-space-autocomplete.md new file mode 100644 index 0000000..d52e5e6 --- /dev/null +++ b/.changeset/fix-ctrl-space-autocomplete.md @@ -0,0 +1,5 @@ +--- +"cruncher": patch +--- + +Fix Ctrl+Space autocomplete so it stays open while typing and can be triggered mid-word. Previously, opening autocomplete with Ctrl+Space would close as soon as you typed a character, and pressing Ctrl+Space after partially typing a word (e.g. "app_c") showed no suggestions. Now the completer remains open, filtering results with fuzzy matching as you type, and closes naturally on space, newline, Escape, or accepting a completion. From d67a104f6d4ef15a49e0bb48f807b98479b0cebc Mon Sep 17 00:00:00 2001 From: Yarin Barnes Date: Sat, 14 Mar 2026 23:52:03 +0200 Subject: [PATCH 3/4] fix(editor): prefer position-filtered suggestions, fall back to all only when needed Use smart contextual suggestions when the parser provides position matches. Only broaden to all suggestions when Ctrl+Space is active and no position-based suggestions cover the cursor. --- .../frontend/components/ui/editor/Editor.tsx | 33 ++++++++++++------- 1 file changed, 21 insertions(+), 12 deletions(-) diff --git a/apps/cruncher/src/processes/frontend/components/ui/editor/Editor.tsx b/apps/cruncher/src/processes/frontend/components/ui/editor/Editor.tsx index f525320..3489ed0 100644 --- a/apps/cruncher/src/processes/frontend/components/ui/editor/Editor.tsx +++ b/apps/cruncher/src/processes/frontend/components/ui/editor/Editor.tsx @@ -280,22 +280,31 @@ export const Editor = React.forwardRef( }, [cursorPosition, value]); const filteredSuggestions = useMemo(() => { - const results = new Map(); - for (const suggestion of suggestions) { - // When user explicitly triggered autocomplete (Ctrl+Space), skip - // position-based filtering so all suggestions are available for - // fuzzy matching against the word at the cursor. - if (!ctrlSpaceOpen) { - if (cursorPosition < suggestion.fromPosition) continue; - if (suggestion.toPosition && cursorPosition > suggestion.toPosition) - continue; - } + // First pass: collect position-matching suggestions (contextual) + const positionFiltered = new Map(); + // Second pass: collect all suggestions (fallback for Ctrl+Space mid-word) + const allDeduped = new Map(); + for (const suggestion of suggestions) { const key = `${suggestion.type}:${suggestion.value}`; - if (!results.has(key)) results.set(key, suggestion); + + if (!allDeduped.has(key)) allDeduped.set(key, suggestion); + + if (cursorPosition < suggestion.fromPosition) continue; + if (suggestion.toPosition && cursorPosition > suggestion.toPosition) + continue; + if (!positionFiltered.has(key)) positionFiltered.set(key, suggestion); } - return Array.from(results.values()) + // Use position-filtered (smart) suggestions when available. + // Fall back to all suggestions only when Ctrl+Space is active + // and no position-based suggestions match the cursor. + const candidates = + positionFiltered.size > 0 || !ctrlSpaceOpen + ? positionFiltered + : allDeduped; + + return Array.from(candidates.values()) .flatMap((suggestion) => { if (!writtenWord) return [{ suggestion, score: 0 }]; const valueToMatch = From 0b1457f31d39c90b2e760fb9649c8ca22c566fa8 Mon Sep 17 00:00:00 2001 From: Yarin Barnes Date: Sun, 15 Mar 2026 00:29:44 +0200 Subject: [PATCH 4/4] fix(editor): prefer position-filtered suggestions, fall back to all only when needed --- .../frontend/components/ui/editor/Editor.tsx | 95 ++++++++++--------- 1 file changed, 51 insertions(+), 44 deletions(-) diff --git a/apps/cruncher/src/processes/frontend/components/ui/editor/Editor.tsx b/apps/cruncher/src/processes/frontend/components/ui/editor/Editor.tsx index 3489ed0..c99b447 100644 --- a/apps/cruncher/src/processes/frontend/components/ui/editor/Editor.tsx +++ b/apps/cruncher/src/processes/frontend/components/ui/editor/Editor.tsx @@ -280,53 +280,60 @@ export const Editor = React.forwardRef( }, [cursorPosition, value]); const filteredSuggestions = useMemo(() => { - // First pass: collect position-matching suggestions (contextual) - const positionFiltered = new Map(); - // Second pass: collect all suggestions (fallback for Ctrl+Space mid-word) - const allDeduped = new Map(); - - for (const suggestion of suggestions) { - const key = `${suggestion.type}:${suggestion.value}`; + const scoreSuggestions = (list: Suggestion[]) => + list + .flatMap((suggestion) => { + if (!writtenWord) return [{ suggestion, score: 0 }]; + const valueToMatch = + suggestion.value.startsWith('"') && !writtenWord.startsWith('"') + ? suggestion.value.slice(1, -1) + : suggestion.value; + if (valueToMatch.toLowerCase() === writtenWord.toLowerCase()) + return []; + const match = fuzzyMatch(valueToMatch, writtenWord); + if (!match.matched) return []; + let score = 0; + if (valueToMatch.startsWith(writtenWord)) score += 100; + for (let i = 1; i < match.indices.length; i++) { + if (match.indices[i] === match.indices[i - 1] + 1) score += 1; + } + if (match.indices.length > 0) score -= match.indices[0]; + return [{ suggestion, score }]; + }) + .sort((a, b) => { + const priorityDiff = compareSuggestions(a.suggestion, b.suggestion); + if (priorityDiff !== 0) return priorityDiff; + return b.score - a.score; + }) + .map((entry) => entry.suggestion); + + const dedupe = (list: Suggestion[]) => { + const map = new Map(); + for (const s of list) { + const key = `${s.type}:${s.value}`; + if (!map.has(key)) map.set(key, s); + } + return Array.from(map.values()); + }; + + // Position-filtered suggestions (contextual/smart) + const positionFiltered = suggestions.filter((s) => { + if (cursorPosition < s.fromPosition) return false; + if (s.toPosition && cursorPosition > s.toPosition) return false; + return true; + }); - if (!allDeduped.has(key)) allDeduped.set(key, suggestion); + const positionResults = scoreSuggestions(dedupe(positionFiltered)); - if (cursorPosition < suggestion.fromPosition) continue; - if (suggestion.toPosition && cursorPosition > suggestion.toPosition) - continue; - if (!positionFiltered.has(key)) positionFiltered.set(key, suggestion); - } + // Prefer position-filtered when they yield results. + // Also prefer them when there's no typed word (nothing to broaden for) + // or when the user didn't explicitly request autocomplete. + if (positionResults.length > 0 || !ctrlSpaceOpen || !writtenWord) + return positionResults; - // Use position-filtered (smart) suggestions when available. - // Fall back to all suggestions only when Ctrl+Space is active - // and no position-based suggestions match the cursor. - const candidates = - positionFiltered.size > 0 || !ctrlSpaceOpen - ? positionFiltered - : allDeduped; - - return Array.from(candidates.values()) - .flatMap((suggestion) => { - if (!writtenWord) return [{ suggestion, score: 0 }]; - const valueToMatch = - suggestion.value.startsWith('"') && !writtenWord.startsWith('"') - ? suggestion.value.slice(1, -1) - : suggestion.value; - const match = fuzzyMatch(valueToMatch, writtenWord); - if (!match.matched) return []; - let score = 0; - if (valueToMatch.startsWith(writtenWord)) score += 100; - for (let i = 1; i < match.indices.length; i++) { - if (match.indices[i] === match.indices[i - 1] + 1) score += 1; - } - if (match.indices.length > 0) score -= match.indices[0]; - return [{ suggestion, score }]; - }) - .sort((a, b) => { - const priorityDiff = compareSuggestions(a.suggestion, b.suggestion); - if (priorityDiff !== 0) return priorityDiff; - return b.score - a.score; - }) - .map((entry) => entry.suggestion); + // Fallback: Ctrl+Space active, typed word present, but no contextual + // matches — broaden to all suggestions so mid-word completion works. + return scoreSuggestions(dedupe(suggestions)); }, [suggestions, cursorPosition, writtenWord, ctrlSpaceOpen]); const acceptCompletion = () => {