feat(ai): implement AI autocomplete (#8300)

This commit is contained in:
naman-bruno
2026-06-23 13:09:17 +05:30
committed by GitHub
parent 27c1970076
commit bf0e9bcf23
16 changed files with 1138 additions and 72 deletions

View File

@@ -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;

View File

@@ -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 <CodeEditor {...props} persistenceScope={persistenceScope} ref={ref} />;
const aiPreferences = useSelector((state) => state.app.preferences?.ai);
return (
<CodeEditor
{...props}
persistenceScope={persistenceScope}
aiPreferences={aiPreferences}
ref={ref}
/>
);
});
CodeEditorWithPersistenceScope.displayName = 'CodeEditor';

View File

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

View File

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

View File

@@ -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 (
<div className="autocomplete-tab flex flex-col gap-3">
<div className="ai-empty-notice px-3.5 py-3 text-xs">
Turn on AI in the Configuration tab to use autocomplete.
</div>
</div>
);
}
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 (
<div className="autocomplete-tab flex flex-col gap-3">
<div className="autocomplete-card">
<div className="autocomplete-header flex items-center justify-between gap-3 px-3.5 py-3">
<div className="flex flex-col gap-0.5 min-w-0">
<span className="text-[12.5px] font-semibold">Inline Autocomplete</span>
<span className="autocomplete-sub text-[11px]">
Ghost-text suggestions in Pre-Request, Post-Response, and Tests scripts
</span>
</div>
<ToggleSwitch
size="m"
isOn={enabled}
handleToggle={() => onToggleEnabled(!enabled)}
data-testid="ai-autocomplete-enabled-toggle"
/>
</div>
</div>
<div className={`autocomplete-card ${enabled ? '' : 'dimmed'}`}>
{blockerMessage && (
<div className="autocomplete-blocker px-3.5 py-3 text-[11px]">
{blockerMessage}
</div>
)}
<div className="autocomplete-row flex items-center justify-between gap-3 px-3.5 py-3">
<div className="flex flex-col gap-0.5 min-w-0">
<span className="text-[11.5px] font-medium">Model</span>
<span className="autocomplete-sub text-[10.5px]">
{hasUsableModel
? 'Lightweight models are recommended for speed'
: 'No model available yet'}
</span>
</div>
<div className="model-select-wrap relative inline-flex items-center">
<select
className="model-select"
value={model || ''}
disabled={!isInteractive}
onChange={(e) => onChangeModel(e.target.value)}
aria-label="Autocomplete model"
data-testid="ai-autocomplete-model-select"
>
<option value="">Auto (fastest available)</option>
{availableModels.map((m) => (
<option key={m.id} value={m.id}>{m.label}</option>
))}
</select>
<IconChevronDown size={12} strokeWidth={1.75} className="model-select-chevron" />
</div>
</div>
<div className="autocomplete-row flex items-center justify-between gap-3 px-3.5 py-3">
<div className="flex flex-col gap-0.5 min-w-0">
<span className="text-[11.5px] font-medium">Trigger</span>
<span className="autocomplete-sub text-[10.5px]">
{activeTrigger?.description}
</span>
</div>
<div className="trigger-pills inline-flex" role="radiogroup" aria-label="Trigger mode">
{TRIGGER_MODES.map((m) => {
const isSelected = (triggerMode || 'debounced') === m.value;
return (
<button
key={m.value}
type="button"
role="radio"
aria-checked={isSelected}
className={`trigger-pill ${isSelected ? 'selected' : ''}`}
disabled={!isInteractive}
onClick={() => onChangeTriggerMode(m.value)}
data-testid={`ai-autocomplete-trigger-${m.value}`}
>
{m.label}
</button>
);
})}
</div>
</div>
<div className="autocomplete-row px-3.5 py-3">
<div className="flex flex-col gap-1">
<span className="text-[11.5px] font-medium">Keymap</span>
<div className="autocomplete-keymap text-[10.5px]">
<kbd>Tab</kbd> accept · <kbd>Esc</kbd> dismiss · <kbd></kbd>+<kbd>\</kbd> trigger
</div>
</div>
</div>
</div>
</div>
);
};
export default AutocompletePane;

View File

@@ -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); }

View File

@@ -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 (
<StyledWrapper className="w-full flex flex-col text-xs min-h-0 max-h-[calc(100%-30px)]">
<div className="section-header">AI</div>
<div className="ai-master flex items-center justify-between gap-4 px-3.5 py-3 mb-4">
<div className="flex flex-col gap-0.5 min-w-0">
<div className="flex items-center gap-2 text-[13px] font-semibold">
<IconStars size={15} strokeWidth={1.75} className="ai-master-icon" />
<span>AI Features</span>
</div>
<span className="ai-master-summary text-[11px]">{summary}</span>
</div>
<ToggleSwitch
size="m"
isOn={formik.values.enabled}
handleToggle={() => formik.setFieldValue('enabled', !formik.values.enabled)}
/>
<div className="ai-tabs flex items-center gap-1" role="tablist" aria-label="AI preferences">
<button
type="button"
role="tab"
aria-selected={activeTab === 'config'}
className={`ai-tab ${activeTab === 'config' ? 'active' : ''}`}
onClick={() => setActiveTab('config')}
data-testid="ai-tab-config"
>
<IconSettings size={14} strokeWidth={1.5} />
Configuration
</button>
<button
type="button"
role="tab"
aria-selected={activeTab === 'autocomplete'}
className={`ai-tab ${activeTab === 'autocomplete' ? 'active' : ''}`}
onClick={() => setActiveTab('autocomplete')}
data-testid="ai-tab-autocomplete"
>
<IconTerminal2 size={14} strokeWidth={1.5} />
Autocomplete
</button>
</div>
{statusError && (
@@ -154,46 +173,84 @@ const AI = () => {
</div>
)}
{!formik.values.enabled && !statusError && (
<div className="ai-empty-notice px-3.5 py-3 text-xs">
Bring your own API key. Bruno talks to providers directly, your keys never leave your machine.
{activeTab === 'config' && (
<div className="ai-tab-panel" role="tabpanel">
<div className="ai-master flex items-center justify-between gap-4 px-3.5 py-3 mb-4">
<div className="flex flex-col gap-0.5 min-w-0">
<span className="text-[13px] font-semibold">AI Features</span>
<span className="ai-master-summary text-[11px]">
Turn on to configure providers and models. Your keys stay local.
</span>
</div>
<ToggleSwitch
size="m"
isOn={formik.values.enabled}
handleToggle={() => formik.setFieldValue('enabled', !formik.values.enabled)}
/>
</div>
{!formik.values.enabled && !statusError && (
<div className="ai-empty-notice px-3.5 py-3 text-xs">
Bring your own API key. Bruno talks to providers directly, your keys never leave your machine.
</div>
)}
{formik.values.enabled && status && (
<>
<div className="ai-section-header text-[11px] font-medium uppercase tracking-wider mb-2">
Providers
</div>
<div className="flex flex-col gap-1.5">
{providerIds.map((id) => {
const provider = status.providers[id];
const providerEnabled = get(formik.values, `providers.${id}.enabled`, false);
const providerToggle = (
<ToggleSwitch
size="s"
isOn={providerEnabled}
handleToggle={() =>
formik.setFieldValue(`providers.${id}.enabled`, !providerEnabled)}
/>
);
return (
<ProviderCard
key={id}
provider={provider}
providerEnabled={providerEnabled}
providerToggle={providerToggle}
models={modelsByProvider[id] || []}
isModelEnabled={isModelEnabled}
onToggleModel={handleToggleModel}
onStatusChange={(next) => setStatus(next)}
/>
);
})}
</div>
</>
)}
</div>
)}
{formik.values.enabled && status && (
<>
<div className="ai-section-header text-[11px] font-medium uppercase tracking-wider mt-[18px] mb-2">
Providers
</div>
<div className="flex flex-col gap-1.5">
{providerIds.map((id) => {
const provider = status.providers[id];
const providerEnabled = get(formik.values, `providers.${id}.enabled`, false);
const providerToggle = (
<ToggleSwitch
size="s"
isOn={providerEnabled}
handleToggle={() =>
formik.setFieldValue(`providers.${id}.enabled`, !providerEnabled)}
/>
);
return (
<ProviderCard
key={id}
provider={provider}
providerEnabled={providerEnabled}
providerToggle={providerToggle}
models={modelsByProvider[id] || []}
isModelEnabled={isModelEnabled}
onToggleModel={handleToggleModel}
onStatusChange={(next) => setStatus(next)}
/>
);
})}
</div>
</>
{activeTab === 'autocomplete' && (
<div className="ai-tab-panel" role="tabpanel">
<AutocompletePane
aiEnabled={formik.values.enabled}
enabled={formik.values.autocomplete?.enabled !== false}
model={formik.values.autocomplete?.model || ''}
triggerMode={formik.values.autocomplete?.triggerMode || 'debounced'}
availableModels={usableModels}
hasConfiguredProvider={Boolean(
status && Object.entries(status.providers || {}).some(
([providerId, p]) => 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)}
/>
</div>
)}
</StyledWrapper>
);

View File

@@ -127,6 +127,7 @@ const Script = ({ item, collection }) => {
<CodeEditor
ref={preRequestEditorRef}
collection={collection}
item={item}
docKey="script:pre-request"
value={requestScript || ''}
theme={displayedTheme}
@@ -137,6 +138,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 }) => {
<CodeEditor
ref={postResponseEditorRef}
collection={collection}
item={item}
docKey="script:post-response"
value={responseScript || ''}
theme={displayedTheme}
@@ -164,6 +167,7 @@ const Script = ({ item, collection }) => {
onRun={onRun}
onSave={onSave}
showHintsFor={['req', 'res', 'bru']}
scriptType="post-response"
initialScroll={postResScroll}
onScroll={setPostResScroll}
/>

View File

@@ -45,6 +45,7 @@ const Tests = ({ item, collection }) => {
<CodeEditor
ref={testsEditorRef}
collection={collection}
item={item}
docKey="tests"
value={tests || ''}
theme={displayedTheme}
@@ -55,6 +56,7 @@ const Tests = ({ item, collection }) => {
onRun={onRun}
onSave={onSave}
showHintsFor={['req', 'res', 'bru']}
scriptType="tests"
initialScroll={testsScroll}
onScroll={setTestsScroll}
/>

View File

@@ -68,7 +68,12 @@ const initialState = {
anthropic: { enabled: false }
},
models: {},
defaultModel: ''
defaultModel: '',
autocomplete: {
enabled: true,
model: '',
triggerMode: 'debounced'
}
}
},
generateCode: {

View File

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

View File

@@ -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 <br> 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);
};
};

View File

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

View File

@@ -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 \`<CURSOR>\` 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}<CURSOR>${trimmedSuffix}\n\`\`\``);
sections.push('Continue from <CURSOR>. 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 <CURSOR> marker, keep only what comes after it
const cursorIdx = out.indexOf('<CURSOR>');
if (cursorIdx >= 0) out = out.slice(cursorIdx + '<CURSOR>'.length);
return out;
};
module.exports = {
buildSystemPrompt,
buildUserPrompt,
STOP_SEQUENCES,
cleanSuggestion
};

Binary file not shown.

View File

@@ -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()
});