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'} + +
+
+ + +
+
+ +
+
+ Trigger + + {activeTrigger?.description} + +
+
+ {TRIGGER_MODES.map((m) => { + const isSelected = (triggerMode || 'debounced') === m.value; + return ( + + ); + })} +
+
+ +
+
+ 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)} - /> +
+ +
{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() });