diff --git a/packages/bruno-app/src/components/CodeEditor/StyledWrapper.js b/packages/bruno-app/src/components/CodeEditor/StyledWrapper.js
index a117d48dd..751119b47 100644
--- a/packages/bruno-app/src/components/CodeEditor/StyledWrapper.js
+++ b/packages/bruno-app/src/components/CodeEditor/StyledWrapper.js
@@ -45,6 +45,15 @@ const StyledWrapper = styled.div`
text-decoration: underline;
}
+ .cm-ghost-text-ai {
+ opacity: 0.45;
+ color: ${(props) => props.theme.colors.text.muted};
+ font-style: italic;
+ pointer-events: none;
+ user-select: none;
+ white-space: pre;
+ }
+
/* Removes the glow outline around the folded json */
.CodeMirror-foldmarker {
text-shadow: none;
diff --git a/packages/bruno-app/src/components/CodeEditor/index.js b/packages/bruno-app/src/components/CodeEditor/index.js
index 38cb1ace5..2bfd0e92b 100644
--- a/packages/bruno-app/src/components/CodeEditor/index.js
+++ b/packages/bruno-app/src/components/CodeEditor/index.js
@@ -6,9 +6,12 @@
*/
import React, { createRef } from 'react';
+import { useSelector } from 'react-redux';
import { debounce, isEqual } from 'lodash';
import { defineCodeMirrorBrunoVariablesMode } from 'utils/common/codemirror';
import { setupAutoComplete, showRootHints } from 'utils/codemirror/autocomplete';
+import { setupAiAutocomplete } from 'utils/codemirror/aiGhostText';
+import { buildAutocompleteContext } from 'utils/ai';
import StyledWrapper from './StyledWrapper';
import * as jsonlint from '@prantlf/jsonlint';
import { JSHINT } from 'jshint';
@@ -260,6 +263,24 @@ class CodeEditor extends React.Component {
autoCompleteOptions
);
+ // AI ghost-text autocomplete (script editors only). Stays inert until
+ // the user has both enabled AI and configured a provider.
+ if (this.props.scriptType) {
+ this.aiAutocompleteCleanup = setupAiAutocomplete(editor, {
+ scriptType: this.props.scriptType,
+ isEnabled: () => {
+ const ai = this.props.aiPreferences;
+ return Boolean(ai?.enabled) && ai?.autocomplete?.enabled !== false;
+ },
+ getTriggerMode: () => this.props.aiPreferences?.autocomplete?.triggerMode || 'debounced',
+ getContext: () => buildAutocompleteContext({
+ item: this.props.item,
+ collection: this.props.collection,
+ scriptType: this.props.scriptType
+ })
+ });
+ }
+
setupLinkAware(editor);
// Setup lint error tooltip on line number hover
@@ -392,6 +413,7 @@ class CodeEditor extends React.Component {
});
}
+ this.aiAutocompleteCleanup?.();
this.editor?._destroyLinkAware?.();
this.editor.off('change', this._onEdit);
@@ -470,7 +492,15 @@ class CodeEditor extends React.Component {
const CodeEditorWithPersistenceScope = React.forwardRef((props, ref) => {
const persistenceScope = usePersistenceScope();
- return ;
+ const aiPreferences = useSelector((state) => state.app.preferences?.ai);
+ return (
+
+ );
});
CodeEditorWithPersistenceScope.displayName = 'CodeEditor';
diff --git a/packages/bruno-app/src/components/CollectionSettings/Script/index.js b/packages/bruno-app/src/components/CollectionSettings/Script/index.js
index 8bceb42ad..5ff55f52f 100644
--- a/packages/bruno-app/src/components/CollectionSettings/Script/index.js
+++ b/packages/bruno-app/src/components/CollectionSettings/Script/index.js
@@ -137,6 +137,7 @@ const Script = ({ collection }) => {
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}
showHintsFor={['req', 'bru']}
+ scriptType="pre-request"
initialScroll={preReqScroll}
onScroll={setPreReqScroll}
/>
@@ -162,6 +163,7 @@ const Script = ({ collection }) => {
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}
showHintsFor={['req', 'res', 'bru']}
+ scriptType="post-response"
initialScroll={postResScroll}
onScroll={setPostResScroll}
/>
diff --git a/packages/bruno-app/src/components/FolderSettings/Script/index.js b/packages/bruno-app/src/components/FolderSettings/Script/index.js
index f3039aab0..4056eafdb 100644
--- a/packages/bruno-app/src/components/FolderSettings/Script/index.js
+++ b/packages/bruno-app/src/components/FolderSettings/Script/index.js
@@ -142,6 +142,7 @@ const Script = ({ collection, folder }) => {
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}
showHintsFor={['req', 'bru']}
+ scriptType="pre-request"
initialScroll={preReqScroll}
onScroll={setPreReqScroll}
/>
@@ -167,6 +168,7 @@ const Script = ({ collection, folder }) => {
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}
showHintsFor={['req', 'res', 'bru']}
+ scriptType="post-response"
initialScroll={postResScroll}
onScroll={setPostResScroll}
/>
diff --git a/packages/bruno-app/src/components/Preferences/AI/AutocompletePane.js b/packages/bruno-app/src/components/Preferences/AI/AutocompletePane.js
new file mode 100644
index 000000000..503867af2
--- /dev/null
+++ b/packages/bruno-app/src/components/Preferences/AI/AutocompletePane.js
@@ -0,0 +1,160 @@
+import { IconChevronDown } from '@tabler/icons';
+import ToggleSwitch from 'components/ToggleSwitch';
+
+/**
+ * Autocomplete tab content. Sibling of the Configuration tab inside
+ * Preferences > AI.
+ *
+ * - master AI off → notice only; the whole card is hidden
+ * - no provider configured → notice in the card body, controls disabled
+ * - no enabled model → notice in the card body, controls disabled
+ * - everything on → fully interactive
+ */
+
+const TRIGGER_MODES = [
+ {
+ value: 'aggressive',
+ label: 'Aggressive',
+ description: 'Suggest after every keystroke'
+ },
+ {
+ value: 'debounced',
+ label: 'Debounced',
+ description: 'Suggest after you pause typing (default)'
+ },
+ {
+ value: 'manual',
+ label: 'Manual',
+ description: 'Only on ⌘+\\ / Ctrl+\\'
+ }
+];
+
+const AutocompletePane = ({
+ aiEnabled,
+ enabled,
+ model,
+ triggerMode,
+ availableModels,
+ hasConfiguredProvider,
+ onToggleEnabled,
+ onChangeModel,
+ onChangeTriggerMode
+}) => {
+ if (!aiEnabled) {
+ return (
+
+
+ Turn on AI in the Configuration tab to use autocomplete.
+
+
+ );
+ }
+
+ const hasUsableModel = availableModels.length > 0;
+ const isInteractive = enabled && hasUsableModel;
+ const activeTrigger = TRIGGER_MODES.find((m) => m.value === (triggerMode || 'debounced'));
+
+ // Surface the most actionable blocker first when the user can't actually
+ // get suggestions yet.
+ let blockerMessage = null;
+ if (!hasConfiguredProvider) {
+ blockerMessage = 'Add a provider API key in the Configuration tab to enable autocomplete.';
+ } else if (!hasUsableModel) {
+ blockerMessage = 'No models are available. Enable a model on its provider card in Configuration.';
+ }
+
+ return (
+
+
+
+
+ Inline Autocomplete
+
+ Ghost-text suggestions in Pre-Request, Post-Response, and Tests scripts
+
+
+
onToggleEnabled(!enabled)}
+ data-testid="ai-autocomplete-enabled-toggle"
+ />
+
+
+
+
+ {blockerMessage && (
+
+ {blockerMessage}
+
+ )}
+
+
+
+ Model
+
+ {hasUsableModel
+ ? 'Lightweight models are recommended for speed'
+ : 'No model available yet'}
+
+
+
+ onChangeModel(e.target.value)}
+ aria-label="Autocomplete model"
+ data-testid="ai-autocomplete-model-select"
+ >
+ Auto (fastest available)
+ {availableModels.map((m) => (
+ {m.label}
+ ))}
+
+
+
+
+
+
+
+ Trigger
+
+ {activeTrigger?.description}
+
+
+
+ {TRIGGER_MODES.map((m) => {
+ const isSelected = (triggerMode || 'debounced') === m.value;
+ return (
+ onChangeTriggerMode(m.value)}
+ data-testid={`ai-autocomplete-trigger-${m.value}`}
+ >
+ {m.label}
+
+ );
+ })}
+
+
+
+
+
+
Keymap
+
+ Tab accept · Esc dismiss · ⌘ +\ trigger
+
+
+
+
+
+ );
+};
+
+export default AutocompletePane;
diff --git a/packages/bruno-app/src/components/Preferences/AI/StyledWrapper.js b/packages/bruno-app/src/components/Preferences/AI/StyledWrapper.js
index b131588dd..f1382d4f3 100644
--- a/packages/bruno-app/src/components/Preferences/AI/StyledWrapper.js
+++ b/packages/bruno-app/src/components/Preferences/AI/StyledWrapper.js
@@ -3,6 +3,46 @@ import styled from 'styled-components';
const StyledWrapper = styled.div`
color: ${(props) => props.theme.text};
+ .ai-tabs {
+ border-bottom: 1px solid ${(props) => props.theme.input.border};
+ margin-bottom: 14px;
+ }
+
+ .ai-tab {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ padding: 7px 12px;
+ margin-bottom: -1px;
+ font-size: 12px;
+ font-weight: 500;
+ color: ${(props) => props.theme.colors.text.muted};
+ background: transparent;
+ border: none;
+ border-bottom: 2px solid transparent;
+ cursor: pointer;
+ transition: color 0.15s ease, border-color 0.15s ease;
+
+ &:hover:not(.active) {
+ color: ${(props) => props.theme.text};
+ }
+
+ &.active {
+ color: ${(props) => props.theme.text};
+ border-bottom-color: ${(props) => props.theme.colors.accent};
+ }
+
+ svg {
+ color: inherit;
+ }
+ }
+
+ .ai-tab-panel {
+ display: flex;
+ flex-direction: column;
+ min-height: 0;
+ }
+
.ai-master {
border: 1px solid ${(props) => props.theme.input.border};
border-radius: ${(props) => props.theme.border.radius.md};
@@ -230,6 +270,115 @@ const StyledWrapper = styled.div`
color: ${(props) => props.theme.colors.text.muted};
}
+ .autocomplete-card {
+ border: 1px solid ${(props) => props.theme.input.border};
+ border-radius: ${(props) => props.theme.border.radius.md};
+ background: ${(props) => props.theme.input.bg};
+ }
+
+ .autocomplete-sub {
+ color: ${(props) => props.theme.colors.text.muted};
+ }
+
+ .autocomplete-card.dimmed {
+ opacity: 0.55;
+ }
+
+ .autocomplete-row + .autocomplete-row {
+ border-top: 1px dashed ${(props) => props.theme.input.border};
+ }
+
+ .autocomplete-blocker {
+ color: ${(props) => props.theme.colors.text.muted};
+ background: ${(props) => props.theme.input.bg};
+ border-bottom: 1px solid ${(props) => props.theme.input.border};
+ }
+
+ .model-select {
+ appearance: none;
+ -webkit-appearance: none;
+ padding: 4px 24px 4px 8px;
+ font-size: 11.5px;
+ font-family: inherit;
+ border-radius: ${(props) => props.theme.border.radius.sm};
+ border: 1px solid ${(props) => props.theme.input.border};
+ background: ${(props) => props.theme.bg};
+ color: ${(props) => props.theme.text};
+ cursor: pointer;
+ min-width: 160px;
+ transition: border-color 0.15s ease;
+
+ &:hover:not(:disabled) {
+ border-color: ${(props) => props.theme.colors.accent}80;
+ }
+
+ &:focus {
+ outline: none;
+ border-color: ${(props) => props.theme.input.focusBorder};
+ }
+
+ &:disabled {
+ cursor: not-allowed;
+ }
+ }
+
+ .model-select-chevron {
+ position: absolute;
+ right: 6px;
+ pointer-events: none;
+ color: ${(props) => props.theme.colors.text.muted};
+ }
+
+ .trigger-pills {
+ border: 1px solid ${(props) => props.theme.input.border};
+ border-radius: ${(props) => props.theme.border.radius.sm};
+ padding: 2px;
+ background: ${(props) => props.theme.bg};
+ }
+
+ .trigger-pill {
+ padding: 3px 9px;
+ font-size: 11px;
+ font-weight: 500;
+ border: 1px solid transparent;
+ border-radius: ${(props) => props.theme.border.radius.sm};
+ background: transparent;
+ color: ${(props) => props.theme.colors.text.muted};
+ cursor: pointer;
+ transition: color 0.15s ease, background-color 0.15s ease, border-color 0.15s ease;
+
+ &:hover:not(:disabled):not(.selected) {
+ color: ${(props) => props.theme.text};
+ }
+
+ &.selected {
+ color: ${(props) => props.theme.text};
+ background: ${(props) => props.theme.colors.accent}10;
+ border-color: ${(props) => props.theme.colors.accent}55;
+ }
+
+ &:disabled {
+ cursor: not-allowed;
+ }
+ }
+
+ .autocomplete-keymap {
+ color: ${(props) => props.theme.colors.text.muted};
+
+ kbd {
+ display: inline-block;
+ padding: 0 4px;
+ margin: 0 1px;
+ font-family: ${(props) => props.theme.font.monospace || 'monospace'};
+ font-size: 10px;
+ line-height: 1.5;
+ color: ${(props) => props.theme.text};
+ background: ${(props) => props.theme.bg};
+ border: 1px solid ${(props) => props.theme.input.border};
+ border-radius: 3px;
+ }
+ }
+
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
diff --git a/packages/bruno-app/src/components/Preferences/AI/index.js b/packages/bruno-app/src/components/Preferences/AI/index.js
index 11a025241..9604b6c2b 100644
--- a/packages/bruno-app/src/components/Preferences/AI/index.js
+++ b/packages/bruno-app/src/components/Preferences/AI/index.js
@@ -5,18 +5,24 @@ import { useFormik } from 'formik';
import { useDispatch, useSelector } from 'react-redux';
import * as Yup from 'yup';
import toast from 'react-hot-toast';
-import { IconStars } from '@tabler/icons';
+import { IconSettings, IconTerminal2 } from '@tabler/icons';
import { savePreferences } from 'providers/ReduxStore/slices/app';
import ToggleSwitch from 'components/ToggleSwitch';
import { getAiStatus } from 'utils/ai';
import ProviderCard from './ProviderCard';
+import AutocompletePane from './AutocompletePane';
import StyledWrapper from './StyledWrapper';
const aiPreferencesSchema = Yup.object().shape({
enabled: Yup.boolean(),
providers: Yup.object(),
models: Yup.object(),
- defaultModel: Yup.string().max(200).nullable()
+ defaultModel: Yup.string().max(200).nullable(),
+ autocomplete: Yup.object().shape({
+ enabled: Yup.boolean(),
+ model: Yup.string().max(200).nullable(),
+ triggerMode: Yup.string().oneOf(['aggressive', 'debounced', 'manual']).nullable()
+ })
});
const AI = () => {
@@ -24,6 +30,7 @@ const AI = () => {
const preferences = useSelector((state) => state.app.preferences);
const [status, setStatus] = useState(null);
const [statusError, setStatusError] = useState(null);
+ const [activeTab, setActiveTab] = useState('config');
const refreshStatus = useCallback(async () => {
try {
@@ -50,7 +57,12 @@ const AI = () => {
return acc;
}, {}),
models: get(preferences, 'ai.models', {}),
- defaultModel: get(preferences, 'ai.defaultModel', '')
+ defaultModel: get(preferences, 'ai.defaultModel', ''),
+ autocomplete: {
+ enabled: get(preferences, 'ai.autocomplete.enabled', true),
+ model: get(preferences, 'ai.autocomplete.model', ''),
+ triggerMode: get(preferences, 'ai.autocomplete.triggerMode', 'debounced')
+ }
},
validationSchema: aiPreferencesSchema,
onSubmit: () => {}
@@ -65,7 +77,12 @@ const AI = () => {
enabled: values.enabled,
providers: values.providers,
models: values.models,
- defaultModel: values.defaultModel || ''
+ defaultModel: values.defaultModel || '',
+ autocomplete: {
+ enabled: values.autocomplete?.enabled !== false,
+ model: values.autocomplete?.model || '',
+ triggerMode: values.autocomplete?.triggerMode || 'debounced'
+ }
}
})
).catch((err) => {
@@ -112,40 +129,42 @@ const AI = () => {
formik.setFieldValue(`models.${modelId}.enabled`, next);
};
- const summary = useMemo(() => {
- if (!status || !formik.values.enabled) return 'Turn on to configure providers and models';
- const usableProviders = Object.values(status.providers).filter(
- (p) => p.configured && formik.values.providers?.[p.id]?.enabled
- );
- if (usableProviders.length === 0) return 'Add a provider to get started';
- // Count models live from formik + current key status, not the electron-side
- // snapshot which lags behind toggle changes during the save debounce window.
- const totalEnabledModels = (status.models || []).filter((m) => {
+ const usableModels = useMemo(() => {
+ if (!status) return [];
+ return (status.models || []).filter((m) => {
if (!formik.values.providers?.[m.provider]?.enabled) return false;
if (!status.providers?.[m.provider]?.configured) return false;
return isModelEnabled(m.id);
- }).length;
- const plural = (n, s) => `${n} ${s}${n === 1 ? '' : 's'}`;
- return `${plural(usableProviders.length, 'provider')} · ${plural(totalEnabledModels, 'model')} ready`;
- }, [status, formik.values.enabled, formik.values.providers, formik.values.models]);
+ });
+ }, [status, formik.values.providers, formik.values.models]);
return (
AI
-
-
-
-
- AI Features
-
-
{summary}
-
-
formik.setFieldValue('enabled', !formik.values.enabled)}
- />
+
+ setActiveTab('config')}
+ data-testid="ai-tab-config"
+ >
+
+ Configuration
+
+ setActiveTab('autocomplete')}
+ data-testid="ai-tab-autocomplete"
+ >
+
+ Autocomplete
+
{statusError && (
@@ -154,46 +173,84 @@ const AI = () => {
)}
- {!formik.values.enabled && !statusError && (
-
- Bring your own API key. Bruno talks to providers directly, your keys never leave your machine.
+ {activeTab === 'config' && (
+
+
+
+ AI Features
+
+ Turn on to configure providers and models. Your keys stay local.
+
+
+
formik.setFieldValue('enabled', !formik.values.enabled)}
+ />
+
+
+ {!formik.values.enabled && !statusError && (
+
+ Bring your own API key. Bruno talks to providers directly, your keys never leave your machine.
+
+ )}
+
+ {formik.values.enabled && status && (
+ <>
+
+ Providers
+
+
+ {providerIds.map((id) => {
+ const provider = status.providers[id];
+ const providerEnabled = get(formik.values, `providers.${id}.enabled`, false);
+
+ const providerToggle = (
+
+ formik.setFieldValue(`providers.${id}.enabled`, !providerEnabled)}
+ />
+ );
+
+ return (
+ setStatus(next)}
+ />
+ );
+ })}
+
+ >
+ )}
)}
- {formik.values.enabled && status && (
- <>
-
- Providers
-
-
- {providerIds.map((id) => {
- const provider = status.providers[id];
- const providerEnabled = get(formik.values, `providers.${id}.enabled`, false);
-
- const providerToggle = (
-
- formik.setFieldValue(`providers.${id}.enabled`, !providerEnabled)}
- />
- );
-
- return (
- setStatus(next)}
- />
- );
- })}
-
- >
+ {activeTab === 'autocomplete' && (
+
+
p?.configured && formik.values.providers?.[providerId]?.enabled
+ )
+ )}
+ onToggleEnabled={(next) => formik.setFieldValue('autocomplete.enabled', next)}
+ onChangeModel={(next) => formik.setFieldValue('autocomplete.model', next)}
+ onChangeTriggerMode={(next) => formik.setFieldValue('autocomplete.triggerMode', next)}
+ />
+
)}
);
diff --git a/packages/bruno-app/src/components/RequestPane/Script/index.js b/packages/bruno-app/src/components/RequestPane/Script/index.js
index f7ad0ab66..ae9d62924 100644
--- a/packages/bruno-app/src/components/RequestPane/Script/index.js
+++ b/packages/bruno-app/src/components/RequestPane/Script/index.js
@@ -127,6 +127,7 @@ const Script = ({ item, collection }) => {
{
onRun={onRun}
onSave={onSave}
showHintsFor={['req', 'bru']}
+ scriptType="pre-request"
initialScroll={preReqScroll}
onScroll={setPreReqScroll}
/>
@@ -154,6 +156,7 @@ const Script = ({ item, collection }) => {
{
onRun={onRun}
onSave={onSave}
showHintsFor={['req', 'res', 'bru']}
+ scriptType="post-response"
initialScroll={postResScroll}
onScroll={setPostResScroll}
/>
diff --git a/packages/bruno-app/src/components/RequestPane/Tests/index.js b/packages/bruno-app/src/components/RequestPane/Tests/index.js
index a70133e25..a35e956fc 100644
--- a/packages/bruno-app/src/components/RequestPane/Tests/index.js
+++ b/packages/bruno-app/src/components/RequestPane/Tests/index.js
@@ -45,6 +45,7 @@ const Tests = ({ item, collection }) => {
{
onRun={onRun}
onSave={onSave}
showHintsFor={['req', 'res', 'bru']}
+ scriptType="tests"
initialScroll={testsScroll}
onScroll={setTestsScroll}
/>
diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/app.js b/packages/bruno-app/src/providers/ReduxStore/slices/app.js
index b883f5049..22d7dd21d 100644
--- a/packages/bruno-app/src/providers/ReduxStore/slices/app.js
+++ b/packages/bruno-app/src/providers/ReduxStore/slices/app.js
@@ -68,7 +68,12 @@ const initialState = {
anthropic: { enabled: false }
},
models: {},
- defaultModel: ''
+ defaultModel: '',
+ autocomplete: {
+ enabled: true,
+ model: '',
+ triggerMode: 'debounced'
+ }
}
},
generateCode: {
diff --git a/packages/bruno-app/src/utils/ai/index.js b/packages/bruno-app/src/utils/ai/index.js
index 71cf08ec1..685a45f85 100644
--- a/packages/bruno-app/src/utils/ai/index.js
+++ b/packages/bruno-app/src/utils/ai/index.js
@@ -1,5 +1,11 @@
import { callIpc } from 'utils/common/ipc';
-import { isItemAFolder, isItemARequest, sortItemsBySidebarOrder } from 'utils/collections';
+import {
+ flattenItems,
+ getAllVariables,
+ isItemAFolder,
+ isItemARequest,
+ sortItemsBySidebarOrder
+} from 'utils/collections';
/**
* Renderer-side wrapper around the AI IPC channels.
@@ -28,6 +34,16 @@ export const aiGenerateText = (params) =>
export const aiGenerateScript = (params) =>
callIpc('renderer:ai-generate-script', params);
+export const aiAutocomplete = (params) =>
+ callIpc('renderer:ai-autocomplete', params);
+
+export const cancelAiAutocomplete = (requestId) => {
+ const { ipcRenderer } = window;
+ if (ipcRenderer && requestId) {
+ ipcRenderer.send('renderer:ai-autocomplete-cancel', { requestId });
+ }
+};
+
export const buildRequestContextFromItem = (item) => {
if (!item) return null;
const req = item.draft ? item.draft.request : item.request;
@@ -102,6 +118,74 @@ export const buildDocsContextFromFolder = (collection, folder) => {
};
};
+// Variable scopes the autocomplete model is told about. We ship NAMES only —
+// values stay on the client. The model uses these to pick real keys for
+// bru.getEnvVar / bru.getVar / bru.interpolate instead of inventing placeholders.
+const SKIPPED_VAR_KEYS = new Set(['pathParams', 'maskedEnvVariables', 'process']);
+
+const namesFrom = (obj) => {
+ if (!obj || typeof obj !== 'object') return [];
+ return Object.keys(obj).filter((k) => !SKIPPED_VAR_KEYS.has(k));
+};
+
+/**
+ * Build the variable-name lists the model will see. We don't separate by
+ * scope on the client because `getAllVariables` already merges them — the
+ * model just needs the union of names to pick from.
+ */
+const collectVariableNames = (collection, item) => {
+ const all = getAllVariables(collection, item);
+ const names = namesFrom(all);
+ const processEnv = all?.process?.env ? Object.keys(all.process.env) : [];
+ return {
+ variables: names,
+ processEnv: processEnv
+ };
+};
+
+/**
+ * Last N non-empty sibling scripts of the same type in the collection,
+ * excluding the current item. Used as style/idiom reference for the model.
+ */
+const collectSiblingScripts = (collection, currentItem, scriptType, maxCount = 3) => {
+ if (!collection || !scriptType) return [];
+
+ const all = flattenItems(collection.items || []);
+ const out = [];
+ for (const it of all) {
+ if (!isItemARequest(it)) continue;
+ if (currentItem && it.uid === currentItem.uid) continue;
+
+ const req = it.draft ? it.draft.request : it.request;
+ if (!req) continue;
+
+ let script;
+ if (scriptType === 'tests') script = req.tests;
+ else if (scriptType === 'pre-request') script = req.script?.req;
+ else if (scriptType === 'post-response') script = req.script?.res;
+
+ if (typeof script === 'string' && script.trim().length > 0) {
+ out.push({ name: it.name, type: scriptType, script });
+ // Sliding window of the most-recent N. flattenItems walks the tree
+ // top-to-bottom so shifting from the front keeps the last N matches.
+ if (out.length > maxCount) out.shift();
+ }
+ }
+ return out;
+};
+
+/**
+ * Build everything the autocomplete IPC needs about the current request +
+ * its collection environment. Called per request from the editor.
+ */
+export const buildAutocompleteContext = ({ item, collection, scriptType }) => {
+ return {
+ requestContext: item ? buildRequestContextFromItem(item) : null,
+ variableNames: collection ? collectVariableNames(collection, item) : null,
+ siblingScripts: collection ? collectSiblingScripts(collection, item, scriptType) : []
+ };
+};
+
/**
* Start a streaming generation. Subscribes to the corresponding `main:ai-stream-*`
* channels filtered by streamId. Returns a handle with `.stop()` and a promise
diff --git a/packages/bruno-app/src/utils/codemirror/aiGhostText.js b/packages/bruno-app/src/utils/codemirror/aiGhostText.js
new file mode 100644
index 000000000..3db388766
--- /dev/null
+++ b/packages/bruno-app/src/utils/codemirror/aiGhostText.js
@@ -0,0 +1,380 @@
+import { aiAutocomplete, cancelAiAutocomplete } from 'utils/ai';
+
+/**
+ * Inline (ghost-text) AI autocomplete for CodeMirror 5 script editors.
+ *
+ * Suggestion is rendered as a faded bookmark widget at the cursor:
+ * Tab accept the whole suggestion
+ * Esc dismiss
+ * Cmd/Ctrl+\ manually trigger (and only way to trigger in manual mode)
+ *
+ * Trigger modes from preferences:
+ * aggressive -> debounce 100ms (feels keystroke-level but coalesces bursts)
+ * debounced -> debounce 750ms (user pause before request fires)
+ * manual -> no auto-trigger, hotkey only
+ *
+ * A short-lived in-flight request is cancelled on every new keystroke so we
+ * don't burn provider tokens on completions the user has already typed past.
+ */
+
+const TRIGGER_DELAYS = {
+ aggressive: 100,
+ debounced: 750,
+ manual: null
+};
+
+// Per-editor state. WeakMap so it GC's with the editor.
+const STATE = new WeakMap();
+
+const getState = (cm) => {
+ let s = STATE.get(cm);
+ if (!s) {
+ s = {
+ widget: null,
+ suggestion: '',
+ anchor: null, // CodeMirror position where the suggestion was placed
+ timer: null,
+ inflightId: null,
+ // Completion-reuse cache: if the user types characters matching the
+ // suggestion start, we trim the suggestion instead of refetching.
+ reusePrefix: null,
+ reuseSuggestion: null,
+ // Local result cache by (prefix.slice(-300), suffix.slice(0, 100)).
+ local: new Map(),
+ destroyed: false
+ };
+ STATE.set(cm, s);
+ }
+ return s;
+};
+
+const newRequestId = () => `ac-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
+
+const createGhostElement = (text) => {
+ const span = document.createElement('span');
+ span.className = 'cm-ghost-text cm-ghost-text-ai';
+ // A bare \n inside an inline span doesn't wrap — emit one per newline
+ // so multi-line ghost text stacks under the cursor line.
+ const lines = text.split('\n');
+ lines.forEach((line, i) => {
+ if (i > 0) span.appendChild(document.createElement('br'));
+ span.appendChild(document.createTextNode(line));
+ });
+ return span;
+};
+
+const clearGhost = (cm, opts = {}) => {
+ const s = getState(cm);
+ if (s.widget) {
+ s.widget.clear();
+ s.widget = null;
+ }
+ s.suggestion = '';
+ s.anchor = null;
+ if (opts.clearReuse) {
+ s.reusePrefix = null;
+ s.reuseSuggestion = null;
+ }
+};
+
+const showGhost = (cm, suggestion) => {
+ if (!suggestion) return;
+ clearGhost(cm);
+ const s = getState(cm);
+ const cursor = cm.getCursor();
+ const widget = createGhostElement(suggestion);
+ s.widget = cm.setBookmark(cursor, { widget, insertLeft: true });
+ s.suggestion = suggestion;
+ s.anchor = { line: cursor.line, ch: cursor.ch };
+};
+
+const LOCAL_CACHE_MAX = 50;
+const cacheKey = (prefix, suffix) => `${prefix.slice(-300)}<>${suffix.slice(0, 100)}`;
+
+const localCacheGet = (s, prefix, suffix) => {
+ const key = cacheKey(prefix, suffix);
+ if (s.local.has(key)) return s.local.get(key);
+
+ // Prefix-based lookup — if the user has typed chars matching a previous
+ // suggestion start, return the trimmed remainder.
+ for (const [k, v] of s.local) {
+ const [cachedPrefix] = k.split('<>');
+ if (prefix.startsWith(cachedPrefix) && prefix.length > cachedPrefix.length) {
+ const extra = prefix.slice(cachedPrefix.length);
+ if (v.startsWith(extra)) {
+ const remainder = v.slice(extra.length);
+ if (remainder) return remainder;
+ }
+ }
+ }
+ return null;
+};
+
+const localCacheSet = (s, prefix, suffix, suggestion) => {
+ const key = cacheKey(prefix, suffix);
+ if (s.local.has(key)) s.local.delete(key);
+ s.local.set(key, suggestion);
+ while (s.local.size > LOCAL_CACHE_MAX) {
+ s.local.delete(s.local.keys().next().value);
+ }
+};
+
+/**
+ * Snapshot of editor state at the moment a request was queued. Used to
+ * verify the suggestion still applies when it arrives (cursor hasn't moved
+ * away from the position we asked about).
+ */
+const snapshotPosition = (cm) => {
+ const cursor = cm.getCursor();
+ return { line: cursor.line, ch: cursor.ch, idx: cm.indexFromPos(cursor) };
+};
+
+const fetchAndShow = async (cm, options) => {
+ if (!options.isEnabled?.()) return;
+
+ const code = cm.getValue();
+ const cursor = cm.getCursor();
+ const cursorIdx = cm.indexFromPos(cursor);
+ const prefix = code.slice(0, cursorIdx);
+ const suffix = code.slice(cursorIdx);
+
+ if (prefix.length < 3) return;
+
+ // Skip if there's meaningful text after the cursor on the same line —
+ // ghost-text overlap with real code reads confusingly.
+ const restOfLine = cm.getLine(cursor.line).slice(cursor.ch).trim();
+ if (restOfLine && !/^[)\]}"',;\s]*$/.test(restOfLine)) return;
+
+ const s = getState(cm);
+
+ // Local cache (instant)
+ const local = localCacheGet(s, prefix, suffix);
+ if (local) {
+ showGhost(cm, local);
+ s.reusePrefix = prefix;
+ s.reuseSuggestion = local;
+ return;
+ }
+
+ const ctx = options.getContext?.() || {};
+ const requestId = newRequestId();
+ const snapshot = snapshotPosition(cm);
+
+ // Cancel any prior in-flight before issuing a new one.
+ if (s.inflightId) cancelAiAutocomplete(s.inflightId);
+ s.inflightId = requestId;
+
+ try {
+ const result = await aiAutocomplete({
+ requestId,
+ scriptType: options.scriptType,
+ prefix,
+ suffix,
+ requestContext: ctx.requestContext || null,
+ variableNames: ctx.variableNames || null,
+ siblingScripts: ctx.siblingScripts || []
+ });
+
+ if (s.inflightId !== requestId || s.destroyed) return;
+ s.inflightId = null;
+
+ if (!result || result.cancelled || result.disabled) return;
+
+ if (result.error) {
+ // Stay quiet — autocomplete errors shouldn't toast on every keystroke.
+ return;
+ }
+
+ const suggestion = result.suggestion || '';
+ if (!suggestion) return;
+
+ // Bail if the cursor moved while we were waiting.
+ const nowIdx = cm.indexFromPos(cm.getCursor());
+ if (nowIdx !== snapshot.idx) return;
+
+ localCacheSet(s, prefix, suffix, suggestion);
+ s.reusePrefix = prefix;
+ s.reuseSuggestion = suggestion;
+ showGhost(cm, suggestion);
+ } catch (_err) {
+ // Network/IPC errors during typing aren't surfaced.
+ }
+};
+
+/**
+ * If the user has typed characters that match the start of the last shown
+ * suggestion, trim the suggestion and re-render instantly — no API call.
+ */
+const tryReuse = (cm) => {
+ const s = getState(cm);
+ if (!s.reusePrefix || !s.reuseSuggestion) return false;
+
+ const cursor = cm.getCursor();
+ const cursorIdx = cm.indexFromPos(cursor);
+ const prefix = cm.getValue().slice(0, cursorIdx);
+
+ if (!prefix.startsWith(s.reusePrefix)) {
+ s.reusePrefix = null;
+ s.reuseSuggestion = null;
+ return false;
+ }
+
+ const typed = prefix.slice(s.reusePrefix.length);
+ if (typed.length === 0) return false;
+
+ if (s.reuseSuggestion.startsWith(typed)) {
+ const remainder = s.reuseSuggestion.slice(typed.length);
+ if (remainder) {
+ showGhost(cm, remainder);
+ // Update reuse anchor so subsequent typing keeps narrowing.
+ s.reusePrefix = prefix;
+ s.reuseSuggestion = remainder;
+ return true;
+ }
+ }
+
+ // Drift — the user typed something inconsistent with the suggestion.
+ s.reusePrefix = null;
+ s.reuseSuggestion = null;
+ return false;
+};
+
+const scheduleFetch = (cm, options, immediate = false) => {
+ const s = getState(cm);
+ if (s.timer) {
+ clearTimeout(s.timer);
+ s.timer = null;
+ }
+
+ const mode = options.getTriggerMode?.() || 'debounced';
+ if (mode === 'manual' && !immediate) return;
+
+ const delay = immediate ? 0 : (TRIGGER_DELAYS[mode] ?? TRIGGER_DELAYS.debounced);
+ s.timer = setTimeout(() => {
+ s.timer = null;
+ fetchAndShow(cm, options);
+ }, delay);
+};
+
+const acceptAll = (cm) => {
+ const s = getState(cm);
+ if (!s.suggestion) return false;
+ const text = s.suggestion;
+ const cursor = cm.getCursor();
+ clearGhost(cm, { clearReuse: true });
+ cm.replaceRange(text, cursor, cursor, '+ai-autocomplete');
+ return true;
+};
+
+const dismiss = (cm) => {
+ const s = getState(cm);
+ if (!s.suggestion) return false;
+ clearGhost(cm, { clearReuse: true });
+ if (s.inflightId) {
+ cancelAiAutocomplete(s.inflightId);
+ s.inflightId = null;
+ }
+ return true;
+};
+
+/**
+ * Wire up ghost-text AI autocomplete on a CodeMirror 5 editor.
+ *
+ * options:
+ * scriptType 'tests' | 'pre-request' | 'post-response'
+ * getContext() → { requestContext, variableNames, siblingScripts }
+ * isEnabled() → boolean (AI master + autocomplete + has-model gate)
+ * getTriggerMode() → 'aggressive' | 'debounced' | 'manual'
+ *
+ * Returns a cleanup function that detaches every handler and clears state.
+ */
+export const setupAiAutocomplete = (editor, options) => {
+ if (!editor || !options || !options.scriptType) {
+ return () => {};
+ }
+
+ // Drop any pending in-flight request, the answer it'd return is about a
+ // cursor position we've already left. Avoids wasted provider calls and
+ // stale-response races in debounced mode.
+ const abortInflight = (cm) => {
+ const s = getState(cm);
+ if (s.inflightId) {
+ cancelAiAutocomplete(s.inflightId);
+ s.inflightId = null;
+ }
+ };
+
+ const onChange = (cm, changeObj) => {
+ // Ignore our own programmatic inserts so they don't re-fire ghost text.
+ if (changeObj.origin === '+ai-autocomplete') return;
+ if (changeObj.origin !== '+input' && changeObj.origin !== '+delete' && changeObj.origin !== 'paste') {
+ abortInflight(cm);
+ clearGhost(cm);
+ return;
+ }
+ // Try reuse first — if it works, no network round-trip needed.
+ if (tryReuse(cm)) return;
+ abortInflight(cm);
+ clearGhost(cm);
+ scheduleFetch(cm, options);
+ };
+
+ const onCursorActivity = (cm) => {
+ const s = getState(cm);
+ if (!s.widget || !s.anchor) return;
+ const cursor = cm.getCursor();
+ if (cursor.line !== s.anchor.line || cursor.ch !== s.anchor.ch) {
+ clearGhost(cm, { clearReuse: true });
+ }
+ };
+
+ const onBlur = (cm) => {
+ abortInflight(cm);
+ clearGhost(cm, { clearReuse: true });
+ };
+
+ const onKeyDown = (cm, event) => {
+ const s = getState(cm);
+
+ // Manual trigger: Cmd/Ctrl + \
+ if ((event.metaKey || event.ctrlKey) && event.key === '\\') {
+ event.preventDefault();
+ event.stopPropagation();
+ clearGhost(cm);
+ scheduleFetch(cm, options, true);
+ return;
+ }
+
+ if (!s.suggestion) return;
+
+ if (event.key === 'Tab' && !event.shiftKey && !event.altKey) {
+ event.preventDefault();
+ event.stopPropagation();
+ acceptAll(cm);
+ return;
+ }
+
+ if (event.key === 'Escape') {
+ event.preventDefault();
+ event.stopPropagation();
+ dismiss(cm);
+ }
+ };
+
+ editor.on('change', onChange);
+ editor.on('cursorActivity', onCursorActivity);
+ editor.on('blur', onBlur);
+ editor.on('keydown', onKeyDown);
+
+ return () => {
+ const s = getState(editor);
+ s.destroyed = true;
+ if (s.timer) clearTimeout(s.timer);
+ if (s.inflightId) cancelAiAutocomplete(s.inflightId);
+ clearGhost(editor, { clearReuse: true });
+ editor.off('change', onChange);
+ editor.off('cursorActivity', onCursorActivity);
+ editor.off('blur', onBlur);
+ editor.off('keydown', onKeyDown);
+ };
+};
diff --git a/packages/bruno-electron/src/index.js b/packages/bruno-electron/src/index.js
index 21ae958ac..9b5520580 100644
--- a/packages/bruno-electron/src/index.js
+++ b/packages/bruno-electron/src/index.js
@@ -46,6 +46,7 @@ const registerApiSpecIpc = require('./ipc/apiSpec');
const registerGitIpc = require('./ipc/git');
const registerOpenAPISyncIpc = require('./ipc/openapi-sync');
const registerAiIpc = require('./ipc/ai');
+const registerAiAutocompleteIpc = require('./ipc/ai/autocomplete');
const { registerMountIpc } = require('./ipc/mount');
const collectionWatcher = require('./app/collection-watcher');
const WorkspaceWatcher = require('./app/workspace-watcher');
@@ -478,6 +479,7 @@ app.on('ready', async () => {
registerGitIpc(mainWindow);
registerOpenAPISyncIpc(mainWindow);
registerAiIpc(mainWindow);
+ registerAiAutocompleteIpc(mainWindow);
registerMountIpc();
// Internal delegator
diff --git a/packages/bruno-electron/src/ipc/ai/autocomplete-prompts.js b/packages/bruno-electron/src/ipc/ai/autocomplete-prompts.js
new file mode 100644
index 000000000..89abab91b
--- /dev/null
+++ b/packages/bruno-electron/src/ipc/ai/autocomplete-prompts.js
@@ -0,0 +1,170 @@
+/**
+ * Prompts for inline (ghost-text) autocomplete in script editors.
+ *
+ * Separate from script-prompts.js because the surfaces differ:
+ * - script-prompts.js: full-script generation from a user instruction.
+ * - this file: cursor-position completion from prefix/suffix, no user
+ * instruction. Output is appended verbatim at the cursor.
+ */
+
+const BRUNO_API_SUMMARY = `## Bruno runtime APIs (available inside scripts)
+
+bru: env/global/collection/folder/request/runtime vars — get/set/has/delete,
+ interpolate(strOrObj), sleep(ms), sendRequest(cfg), setNextRequest(name),
+ cookies, utils.minifyJson/Xml, getEnvName, getCollectionName, cwd
+
+req: url, method, headers, body, timeout. getUrl/setUrl, getHeader/setHeader,
+ getBody/setBody, getMethod/setMethod, getTimeout/setTimeout. Available in
+ pre-request, post-response and tests.
+
+res: status, headers, body, responseTime. getStatus, getStatusText,
+ getHeader, getHeaders, getBody, getResponseTime, getSize. res('json.path')
+ for JSONPath-style queries. Available in post-response and tests.
+
+test/expect: Chai-style assertions inside test("name", () => { ... }) blocks.
+ Available in tests only.`;
+
+const SCRIPT_CONTEXTS = {
+ 'tests': `Tests run AFTER the response. Globals: bru, req, res, test, expect. Assertions go inside test("name", () => { ... }) blocks. Don't call bru.setEnvVar / setVar — keep tests pure.`,
+ 'pre-request': `Pre-request scripts run BEFORE the HTTP request is sent. Globals: bru, req. res is NOT available. Don't use test() / expect() — those belong in the Tests tab.`,
+ 'post-response': `Post-response scripts run AFTER the response is received, before tests. Globals: bru, req, res. Don't use test() / expect() — those belong in the Tests tab.`
+};
+
+const SCRIPT_TYPE_LABELS = {
+ 'tests': 'a Bruno tests script',
+ 'pre-request': 'a Bruno pre-request script',
+ 'post-response': 'a Bruno post-response script'
+};
+
+const buildSystemPrompt = (scriptType) => {
+ const label = SCRIPT_TYPE_LABELS[scriptType] || 'a Bruno script';
+ const context = SCRIPT_CONTEXTS[scriptType] || '';
+ return `You are an inline code-completion engine for ${label}.
+
+${BRUNO_API_SUMMARY}
+
+## Context for ${scriptType}
+${context}
+
+## Output rules
+
+- Continue the code from the cursor marker \`\` exactly where it is.
+- Output ONLY the characters that should be inserted at the cursor — no markdown, no fences, no commentary, no leading newline.
+- Match the surrounding indentation and quote style.
+- Stop at a natural break (end of statement, end of block) — do not rewrite code that already exists after the cursor.
+- Prefer real variable names from the provided lists over placeholders.
+- Return an empty string if you have nothing useful to add.`;
+};
+
+const truncateLines = (text, maxLines) => {
+ if (!text) return '';
+ const lines = text.split('\n');
+ if (lines.length <= maxLines) return text;
+ return lines.slice(-maxLines).join('\n');
+};
+
+const truncateLinesFromStart = (text, maxLines) => {
+ if (!text) return '';
+ const lines = text.split('\n');
+ if (lines.length <= maxLines) return text;
+ return lines.slice(0, maxLines).join('\n');
+};
+
+const formatRequestContext = (ctx) => {
+ if (!ctx) return '';
+ const parts = [];
+ if (ctx.url || ctx.method) {
+ parts.push(`${ctx.method || 'GET'} ${ctx.url || ''}`.trim());
+ }
+
+ const headers = (ctx.headers || []).filter((h) => h?.enabled && h?.name).slice(0, 12);
+ if (headers.length) {
+ parts.push(`Headers: ${headers.map((h) => h.name).join(', ')}`);
+ }
+
+ const body = ctx.body;
+ if (body && body.mode && body.mode !== 'none') {
+ let bodyText = '';
+ if (body.mode === 'json') bodyText = body.json || '';
+ else if (body.mode === 'text') bodyText = body.text || '';
+ else if (body.mode === 'xml') bodyText = body.xml || '';
+ else if (body.mode === 'graphql') bodyText = body.graphql?.query || '';
+ if (bodyText) {
+ const trimmed = bodyText.slice(0, 400);
+ parts.push(`Body (${body.mode}):\n${trimmed}${bodyText.length > 400 ? '…' : ''}`);
+ }
+ }
+
+ return parts.join('\n');
+};
+
+const formatVariableNames = (vars) => {
+ if (!vars || typeof vars !== 'object') return '';
+ const entries = Object.entries(vars)
+ .filter(([, names]) => Array.isArray(names) && names.length)
+ .map(([scope, names]) => `${scope}: ${names.slice(0, 40).join(', ')}`);
+ return entries.join('\n');
+};
+
+const formatSiblingScripts = (siblings) => {
+ if (!Array.isArray(siblings) || !siblings.length) return '';
+ return siblings
+ .map((s) => {
+ if (!s || !s.script || !s.script.trim()) return null;
+ const code = s.script.slice(0, 400);
+ return `// ${s.name || 'sibling'} (${s.type || 'script'})\n${code}${s.script.length > 400 ? '\n// …' : ''}`;
+ })
+ .filter(Boolean)
+ .join('\n\n');
+};
+
+const buildUserPrompt = ({ prefix, suffix, scriptType, requestContext, variableNames, siblingScripts }) => {
+ // Keep recent context in the prefix (last 80 lines) and a short forward window (30 lines).
+ // Cloud LLMs can handle more, but trimming keeps latency low and the model focused.
+ const trimmedPrefix = truncateLines(prefix || '', 80);
+ const trimmedSuffix = truncateLinesFromStart(suffix || '', 30);
+
+ const sections = [];
+
+ const reqStr = formatRequestContext(requestContext);
+ if (reqStr) sections.push(`### Request being scripted\n${reqStr}`);
+
+ const varStr = formatVariableNames(variableNames);
+ if (varStr) sections.push(`### Available variable names (use these in bru.getEnvVar / bru.getVar / interpolate)\n${varStr}`);
+
+ const siblingStr = formatSiblingScripts(siblingScripts);
+ if (siblingStr) sections.push(`### Sibling ${scriptType} scripts in the same collection (style reference)\n${siblingStr}`);
+
+ sections.push(`### Cursor\n\`\`\`javascript\n${trimmedPrefix}${trimmedSuffix}\n\`\`\``);
+ sections.push('Continue from . Output only the insertion.');
+
+ return sections.join('\n\n');
+};
+
+// Stop sequences for cloud chat models — cut suggestions once they wander
+// into prose or start a new top-level construct.
+const STOP_SEQUENCES = ['```', '\n\ntest(', '\n\n// User:', '\n\nAssistant:'];
+
+// Strip leftover fences, language tags, and quote/prose preambles a model may
+// emit despite the prompt. Never collapses internal whitespace — keeps indent.
+const cleanSuggestion = (raw) => {
+ if (!raw) return '';
+ let out = raw;
+ // Drop leading fences and language tag
+ out = out.replace(/^```[\w-]*\n?/, '');
+ // Drop trailing fence
+ out = out.replace(/\n?```\s*$/, '');
+ // Drop "Here is..." style preambles on a single leading line
+ out = out.replace(/^(?:Here(?:'s| is| are)[^\n]*\n)/i, '');
+ // If model echoed the marker, keep only what comes after it
+ const cursorIdx = out.indexOf('');
+ if (cursorIdx >= 0) out = out.slice(cursorIdx + ''.length);
+ return out;
+};
+
+module.exports = {
+ buildSystemPrompt,
+ buildUserPrompt,
+ STOP_SEQUENCES,
+ cleanSuggestion
+};
diff --git a/packages/bruno-electron/src/ipc/ai/autocomplete.js b/packages/bruno-electron/src/ipc/ai/autocomplete.js
new file mode 100644
index 000000000..650166f44
Binary files /dev/null and b/packages/bruno-electron/src/ipc/ai/autocomplete.js differ
diff --git a/packages/bruno-electron/src/store/preferences.js b/packages/bruno-electron/src/store/preferences.js
index 1e9df37c8..de0bb3a5f 100644
--- a/packages/bruno-electron/src/store/preferences.js
+++ b/packages/bruno-electron/src/store/preferences.js
@@ -80,7 +80,12 @@ const defaultPreferences = {
anthropic: { enabled: false }
},
models: {},
- defaultModel: ''
+ defaultModel: '',
+ autocomplete: {
+ enabled: true,
+ model: '',
+ triggerMode: 'debounced'
+ }
}
};
@@ -157,7 +162,12 @@ const preferencesSchema = Yup.object().shape({
enabled: Yup.boolean(),
providers: Yup.object().optional(),
models: Yup.object().optional(),
- defaultModel: Yup.string().max(200).nullable()
+ defaultModel: Yup.string().max(200).nullable(),
+ autocomplete: Yup.object({
+ enabled: Yup.boolean(),
+ model: Yup.string().max(200).nullable(),
+ triggerMode: Yup.string().oneOf(['aggressive', 'debounced', 'manual']).nullable()
+ }).optional()
}).optional()
});