feat: add support for prompt variables in the bruno app

This commit is contained in:
Bobby Bonestell
2025-10-14 19:39:13 +05:30
committed by Bijin Bruno
parent 2d2a17c90f
commit d28f2f32e9
15 changed files with 321 additions and 9 deletions

View File

@@ -96,6 +96,9 @@ const StyledWrapper = styled.div`
.cm-variable-invalid {
color: red;
}
.cm-variable-prompt {
color: dodgerblue;
}
.CodeMirror-search-hint {
display: inline;

View File

@@ -0,0 +1,41 @@
import React, { useState } from 'react';
import Portal from 'components/Portal';
import Modal from 'components/Modal';
export default function PromptVariablesModal({ title = 'Input Required', prompts, onSubmit, onCancel }) {
const [values, setValues] = useState({});
const handleChange = (prompt, value) => {
setValues((prev) => ({ ...prev, [prompt]: value }));
};
if (!prompts?.length) {
return null;
}
return (
<Portal>
<Modal
size="md"
title={title}
confirmText="Continue"
cancelText="Cancel"
handleConfirm={() => onSubmit(values)}
handleCancel={onCancel}
>
{prompts.map((prompt) => (
<div key={prompt} style={{ marginBottom: 16 }}>
<label style={{ display: 'block', fontWeight: 500, marginBottom: 8 }}>{prompt}:</label>
<input
type="text"
style={{ width: '100%', color: '#333', padding: '8px', borderRadius: '4px', border: '1px solid #ccc' }}
value={values[prompt] || ''}
onChange={(e) => handleChange(prompt, e.target.value)}
autoFocus
/>
</div>
))}
</Modal>
</Portal>
);
}

View File

@@ -50,6 +50,9 @@ const StyledWrapper = styled.div`
.cm-variable-invalid {
color: red;
}
.cm-variable-prompt {
color: blue;
}
.CodeMirror-search-hint {
display: inline;

View File

@@ -237,6 +237,9 @@ const GlobalStyle = createGlobalStyle`
.cm-variable-invalid {
color: ${(props) => props.theme.codemirror.variable.invalid};
}
.cm-variable-prompt {
color: ${(props) => props.theme.codemirror.variable.prompt};
}
}
.CodeMirror-brunoVarInfo {
color: ${(props) => props.theme.codemirror.variable.info.color};

View File

@@ -3,6 +3,7 @@ import { Provider } from 'react-redux';
import { AppProvider } from 'providers/App';
import { ToastProvider } from 'providers/Toaster';
import { HotkeysProvider } from 'providers/Hotkeys';
import { PromptVariablesProvider } from 'providers/PromptVariables';
import ReduxStore from 'providers/ReduxStore';
import ThemeProvider from 'providers/Theme/index';
@@ -44,11 +45,13 @@ function Main({ children }) {
<Provider store={ReduxStore}>
<ThemeProvider>
<ToastProvider>
<AppProvider>
<HotkeysProvider>
{children}
</HotkeysProvider>
</AppProvider>
<PromptVariablesProvider>
<AppProvider>
<HotkeysProvider>
{children}
</HotkeysProvider>
</AppProvider>
</PromptVariablesProvider>
</ToastProvider>
</ThemeProvider>
</Provider>
@@ -57,5 +60,3 @@ function Main({ children }) {
}
export default Main;

View File

@@ -0,0 +1,72 @@
import PromptVariablesModal from 'components/RequestPane/PromptVariables/PromptVariablesModal';
import React, { createContext, useCallback, useState } from 'react';
import { toast } from 'react-hot-toast';
const PromptVariablesContext = createContext();
export function PromptVariablesProvider({ children }) {
const [modalState, setModalState] = useState({ open: false, prompts: [], resolve: null, reject: null });
const prompt = useCallback((prompts) => {
return new Promise((resolve, reject) => {
try {
setModalState({ open: true, prompts, resolve, reject });
} catch (err) {
console.error('PromptVariablesProvider: Error opening prompt modal:', err);
toast.error('Prompt variable(s) detected, but prompt modal is not available. Please ensure PromptVariableProvider is mounted.');
reject(err);
}
});
}, []);
// Expose globally for non-component code (e.g., Redux thunks)
if (typeof window !== 'undefined') {
window.promptForVariables = async (prompts) => {
try {
return await prompt(prompts);
} catch (err) {
if (err !== 'cancelled') console.error('window.promptForVariables encountered an error:', err);
throw err;
}
};
}
const handleSubmit = (values) => {
try {
modalState.resolve(values);
} catch (err) {
console.error('PromptVariablesProvider: Error resolving prompt values:', err);
}
setModalState({ open: false, prompts: [], resolve: null, reject: null });
};
const handleCancel = () => {
try {
modalState.reject('cancelled');
} catch (err) {
console.error('PromptVariablesProvider: Error rejecting prompt:', err);
}
setModalState({ open: false, prompts: [], resolve: null, reject: null });
};
try {
return (
<PromptVariablesContext.Provider value={{ prompt }}>
{children}
{modalState.open && (
<PromptVariablesModal
title="Input Required"
prompts={modalState.prompts}
onSubmit={handleSubmit}
onCancel={handleCancel}
/>
)}
</PromptVariablesContext.Provider>
);
} catch (err) {
console.error('PromptVariablesProvider: Error rendering provider or modal:', err);
return children;
}
}
export default PromptVariablesProvider;

View File

@@ -1,5 +1,5 @@
import { collectionSchema, environmentSchema, itemSchema } from '@usebruno/schema';
import { parseQueryParams } from '@usebruno/common/utils';
import { parseQueryParams, extractPromptVariables } from '@usebruno/common/utils';
import cloneDeep from 'lodash/cloneDeep';
import filter from 'lodash/filter';
import find from 'lodash/find';
@@ -397,6 +397,29 @@ export const sendRequest = (item, collectionUid) => (dispatch, getState) => {
const requestUid = uuid();
itemCopy.requestUid = requestUid;
// Ensure window contains promptForVariables function
if (typeof window.promptForVariables === 'function') {
// Attempt to extract unique prompt variables from anywhere in the requestExpand commentComment on line R260ResolvedCode has comments. Press enter to view.
const uniquePrompts = extractPromptVariables(itemCopy.draft?.request ?? itemCopy.request);
if (uniquePrompts?.length > 0) {
try {
// Prompt user for values if any prompt variables are found
let userValues = await window.promptForVariables(uniquePrompts);
// Populate runtimeVariables with user input for prompt variables
for (const prompt of uniquePrompts) {
collectionCopy.runtimeVariables[`?${prompt}`] = userValues[prompt] ?? '';
}
} catch (error) {
if (error === 'cancelled') {
return resolve(); // Resolve without error if user cancels prompt
}
reject(error);
}
}
}
await dispatch(
updateResponsePaneScrollPosition({
uid: state.tabs.activeTabUid,

View File

@@ -287,6 +287,7 @@ const darkTheme = {
variable: {
valid: 'rgb(11 178 126)',
invalid: '#f06f57',
prompt: 'dodgerblue',
info: {
color: '#ce9178',
bg: 'rgb(48,48,49)',

View File

@@ -288,6 +288,7 @@ const lightTheme = {
variable: {
valid: '#047857',
invalid: 'rgb(185, 28, 28)',
prompt: 'dodgerblue',
info: {
color: 'rgb(52, 52, 52)',
bg: 'white',

View File

@@ -30,6 +30,12 @@ export const defineCodeMirrorBrunoVariablesMode = (_variables, mode, highlightPa
while ((ch = stream.next()) != null) {
if (ch === '}' && stream.peek() === '}') {
stream.eat('}');
// Prompt variable: starts with '?'
if (word.startsWith('?')) {
return `variable-prompt`;
}
// Check if it's a mock variable (starts with $) and exists in mockDataFunctions
const isMockVariable = word.startsWith('$') && mockDataFunctions.hasOwnProperty(word.substring(1));
const found = isMockVariable || pathFoundInVariables(word, variables);

View File

@@ -25,7 +25,7 @@ const { NtlmClient } = require('axios-ntlm');
const { addDigestInterceptor } = require('@usebruno/requests');
const { getCACertificates } = require('@usebruno/requests');
const { getOAuth2Token } = require('../utils/oauth2');
const { encodeUrl, buildFormUrlEncodedPayload } = require('@usebruno/common').utils;
const { encodeUrl, buildFormUrlEncodedPayload, extractPromptVariables } = require('@usebruno/common').utils;
const onConsoleLog = (type, args) => {
console[type](...args);
@@ -74,6 +74,38 @@ const runSingleRequest = async function (
request = await prepareRequest(item, collection);
// Detect prompt variables before proceeding
const promptVars = extractPromptVariables(request);
if (promptVars.length > 0) {
const errorMsg = 'Prompt variables detected in request. CLI execution is not supported for requests with prompt variables.';
console.log(chalk.red(stripExtension(relativeItemPathname)) + chalk.dim(` (${errorMsg})`));
return {
test: {
filename: relativeItemPathname
},
request: {
method: request.method,
url: request.url,
headers: request.headers,
data: request.data
},
response: {
status: 'skipped',
statusText: errorMsg,
data: null,
responseTime: 0
},
error: null,
status: 'skipped',
skipped: true,
assertionResults: [],
testResults: [],
preRequestTestResults: [],
postResponseTestResults: [],
shouldStopRunnerExecution
};
}
request.__bruno__executionMode = 'cli';
const scriptingConfig = get(brunoConfig, 'scripts', {});

View File

@@ -11,3 +11,8 @@ export {
export {
patternHasher
} from './template-hasher';
export {
extractPromptVariables,
extractPromptVariablesFromString
} from './prompt-variables';

View File

@@ -0,0 +1,54 @@
import { describe, expect, it } from '@jest/globals';
import { extractPromptVariables, extractPromptVariablesFromString } from './prompt-variables';
describe('prompt variable utils', () => {
describe('extractPromptVariablesFromString', () => {
it('should extract prompt variables', () => {
expect(extractPromptVariablesFromString('Hello {{?world}}')).toEqual(['world']);
expect(extractPromptVariablesFromString('No prompts here')).toEqual([]);
expect(extractPromptVariablesFromString('Multiple {{?prompts}} in {{?one}} string')).toEqual(['prompts', 'one']);
});
it('should deduplicate prompt variables', () => {
// Strings
expect(extractPromptVariables('{{?world}} prompt here Hello {{?world}}')).toEqual(['world']);
expect(extractPromptVariables('Multiple {{?prompts}} in {{?one}} string plus another {{?one}}')).toEqual(['prompts', 'one']);
});
});
describe('extractPromptVariables', () => {
it('should extract prompt variables from strings', () => {
expect(extractPromptVariables('Hello {{?world}}')).toEqual(['world']);
expect(extractPromptVariables('No prompts here')).toEqual([]);
expect(extractPromptVariables('Multiple {{?prompts}} in {{?one}} string')).toEqual(['prompts', 'one']);
});
it('should extract prompt variables from objects', () => {
expect(extractPromptVariables({ text: 'Hello {{?world}}' })).toEqual(['world']);
expect(extractPromptVariables({ noPrompt: 'No prompt here' })).toEqual([]);
expect(extractPromptVariables({ prompt1: 'Hello {{?world}}', prompt2: 'Another {{?test}}' })).toEqual(['world', 'test']);
});
it('should extract prompt variables from arrays', () => {
// Strings
expect(extractPromptVariables(['No prompts here', 'Hello {{?world}}'])).toEqual(['world']);
expect(extractPromptVariables(['Multiple {{?prompts}} in {{?one}} string', 'Another {{?test}} string'])).toEqual(['prompts', 'one', 'test']);
// Objects
expect(extractPromptVariables([{ prompt: 'Hello {{?world}}', noprompt: 'No prompt here' }, { noprompt: '' }])).toEqual(['world']);
// Nested arrays
expect(extractPromptVariables(['Prompt {{?here}}', ['Hello {{?world}}', 'Another {{?test}} string']])).toEqual(['here', 'world', 'test']);
// Mixed data types
expect(extractPromptVariables([{ text: 'Multiple {{?prompts}} in {{?one}} string', noPrompt: 'No prompt here' }, ['Another {{?test}} string', { prompt: '{{?nested}}', no: 'prompt' }]])).toEqual(['prompts', 'one', 'test', 'nested']);
});
it('should deduplicate prompt variables', () => {
// Strings
expect(extractPromptVariables(['{{?world}} prompt here', 'Hello {{?world}}'])).toEqual(['world']);
expect(extractPromptVariables(['Multiple {{?prompts}} in {{?one}} string', 'Another {{?one}} string'])).toEqual(['prompts', 'one']);
});
});
});

View File

@@ -0,0 +1,45 @@
/**
* Extract prompt variables matching {{?<Prompt Text>}} from a string.
* @param {string} str - The input string.
* @returns {string[]} - An array of extracted prompt variables.
*/
export const extractPromptVariablesFromString = (str: string): string[] => {
const regex = /{{\?([^}]+)}}/g;
const prompts = new Set<string>();
let match;
while ((match = regex.exec(str)) !== null) {
prompts.add(match[1].trim());
}
return Array.from(prompts);
};
/**
* Extract prompt variables from an object.
* @param {*} obj - The input object.
* @returns {string[]} - An array of extracted prompt variables.
*/
export function extractPromptVariables(obj: any): string[] {
const prompts = new Set<string>();
try {
if (typeof obj === 'string') {
// Extract prompt variables from strings
const extracted = extractPromptVariablesFromString(obj);
extracted.forEach((prompt) => prompts.add(prompt));
} else if (Array.isArray(obj)) {
// Recursively extract from array elements
for (const item of obj) {
const extracted = extractPromptVariables(item);
extracted.forEach((prompt) => prompts.add(prompt));
}
} else if (typeof obj === 'object' && obj !== null) {
// Recursively extract from object properties
for (const key in obj) {
const extracted = extractPromptVariables(obj[key]);
extracted.forEach((prompt) => prompts.add(prompt));
}
}
} catch (error) {
console.error('Error extracting prompt variables:', error);
}
return Array.from(prompts);
}

View File

@@ -11,6 +11,7 @@ const { each, get, extend, cloneDeep, merge } = require('lodash');
const { NtlmClient } = require('axios-ntlm');
const { VarsRuntime, AssertRuntime, ScriptRuntime, TestRuntime } = require('@usebruno/js');
const { encodeUrl } = require('@usebruno/common').utils;
const { extractPromptVariables } = require('@usebruno/common').utils;
const { interpolateString } = require('./interpolate-string');
const { resolveAwsV4Credentials, addAwsV4Interceptor } = require('./awsv4auth-helper');
const { addDigestInterceptor } = require('@usebruno/requests');
@@ -1066,6 +1067,27 @@ const registerNetworkIpc = (mainWindow) => {
continue;
}
const promptVars = extractPromptVariables(request);
if (promptVars.length > 0) {
mainWindow.webContents.send('main:run-folder-event', {
type: 'runner-request-skipped',
error: 'Request has been skipped due to containing prompt variables',
responseReceived: {
status: 'skipped',
statusText: 'Prompt variables detected in request. Runner execution is not supported for requests with prompt variables.',
data: null,
responseTime: 0,
headers: null
},
...eventData
});
currentRequestIndex++;
continue;
}
const request = await prepareRequest(item, collection, abortController);
request.__bruno__executionMode = 'runner';