Editor Integration
The autocomplete API returns plain data objects with no DOM or framework dependency. Here is how to wire it into a real editor.
Basic input with dropdown
The simplest integration: listen for input events, call complete(), and render results into a list.
const input = document.querySelector('input')
const dropdown = document.querySelector('.dropdown')
input.addEventListener('input', () => {
const items = ac.complete(input.value, input.selectionStart)
if (items.length === 0) {
dropdown.hidden = true
return
}
dropdown.replaceChildren()
for (const c of items) {
const li = document.createElement('li')
li.dataset.insert = c.insertText ?? c.label
const label = document.createElement('span')
label.className = 'label'
label.textContent = c.label
const detail = document.createElement('span')
detail.className = 'detail'
detail.textContent = c.detail ?? ''
li.append(label, detail)
dropdown.appendChild(li)
}
dropdown.hidden = false
})Handling insertText and cursorOffset
When a user picks a completion, use insertText (falling back to label) and position the cursor using cursorOffset if present:
function applyCompletion(input, completion, replaceFrom) {
const text = completion.insertText ?? completion.label
const before = input.value.slice(0, replaceFrom)
const after = input.value.slice(input.selectionStart)
input.value = before + text + after
// Position cursor: use cursorOffset if provided, otherwise end of insert
const cursorPos = completion.cursorOffset != null
? replaceFrom + completion.cursorOffset
: replaceFrom + text.length
input.setSelectionRange(cursorPos, cursorPos)
}cursorOffset places the cursor inside argument placeholders
cursorOffset is set on completions like filter(.) so the cursor lands between the parentheses, ready for the user to type a lambda predicate.
Monaco Editor
Monaco (the editor that powers VS Code) gives you a full code-editing surface with a suggestion widget, theming, and syntax highlighting. Bonsai plugs into it with a Monarch grammar for highlighting and a completion provider backed by the same createAutocomplete instance you use everywhere else.
You need a monaco instance first. Either bundle the monaco-editor package with your app (recommended: self-hosted and version-locked) or load it from a CDN. The integration code below is identical either way; it assumes monaco and an autocomplete instance ac are in scope:
import { bonsai } from 'bonsai-js'
import { strings, arrays, math } from 'bonsai-js/stdlib'
import { createAutocomplete } from 'bonsai-js/autocomplete'
const expr = bonsai().use(strings).use(arrays).use(math)
let context = { user: { name: 'Alice', age: 25 } }
const ac = createAutocomplete(expr, { context })1. Register the language and syntax highlighting
Register a bonsai language and give it a Monarch tokenizer so expressions are colored:
monaco.languages.register({ id: 'bonsai' })
monaco.languages.setMonarchTokensProvider('bonsai', {
tokenizer: {
root: [
[/\|>/, 'operator.pipe'],
[/[=><!]+/, 'operator'],
[/[&|]{2}/, 'operator'],
[/\?\?/, 'operator'],
[/\?\./, 'operator'],
[/"[^"]*"/, 'string'],
[/'[^']*'/, 'string'],
[/`[^`]*`/, 'string'],
[/\b(true|false|null|undefined)\b/, 'keyword'],
[/\b\d+(\.\d+)?\b/, 'number'],
[/[a-zA-Z_]\w*/, 'identifier'],
[/\./, 'delimiter'],
],
},
})2. Wire autocomplete into a completion provider
This is the key step: map each Bonsai completion to Monaco's CompletionItem format. sortText preserves Bonsai's ranking, and the range replaces the current word so accepting a suggestion does the right thing.
const kindMap = {
property: monaco.languages.CompletionItemKind.Field,
method: monaco.languages.CompletionItemKind.Method,
transform: monaco.languages.CompletionItemKind.Function,
function: monaco.languages.CompletionItemKind.Function,
variable: monaco.languages.CompletionItemKind.Variable,
keyword: monaco.languages.CompletionItemKind.Keyword,
}
monaco.languages.registerCompletionItemProvider('bonsai', {
triggerCharacters: ['.', '|', ' '],
provideCompletionItems(model, position) {
const text = model.getValueInRange({
startLineNumber: 1, startColumn: 1,
endLineNumber: position.lineNumber, endColumn: position.column,
})
const items = ac.complete(text, text.length)
const word = model.getWordUntilPosition(position)
return {
suggestions: items.map(c => ({
label: c.label,
kind: kindMap[c.kind] || monaco.languages.CompletionItemKind.Text,
detail: c.detail,
insertText: c.insertText ?? c.label,
range: {
startLineNumber: position.lineNumber,
startColumn: word.startColumn,
endLineNumber: position.lineNumber,
endColumn: position.column,
},
sortText: String(c.sortPriority + 10000).padStart(8, '0'),
})),
}
},
})3. Create the editor
Create the editor on the bonsai language. The options below strip Monaco down to a single expression line and let suggestions trigger as you type:
const editor = monaco.editor.create(document.getElementById('editor'), {
value: 'user.',
language: 'bonsai',
minimap: { enabled: false },
lineNumbers: 'off',
folding: false,
fontSize: 15,
scrollBeyondLastLine: false,
suggestOnTriggerCharacters: true,
quickSuggestions: true,
wordBasedSuggestions: 'off',
acceptSuggestionOnEnter: 'on',
automaticLayout: true,
})
// Evaluate on every change
editor.onDidChangeModelContent(() => {
try {
const result = expr.evaluateSync(editor.getValue().trim(), context)
console.log(result)
} catch (e) {
console.error((e as Error).message)
}
})4. Optional: matching themes
Define dark and light themes so the editor matches your brand, and call monaco.editor.setTheme(...) when your app's color scheme changes:
monaco.editor.defineTheme('bonsai-dark', {
base: 'vs-dark',
inherit: true,
rules: [
{ token: 'operator.pipe', foreground: '10b981', fontStyle: 'bold' },
{ token: 'operator', foreground: '10b981' },
{ token: 'string', foreground: '34d399' },
{ token: 'keyword', foreground: 'c084fc' },
{ token: 'number', foreground: '60a5fa' },
],
colors: { 'editor.background': '#0e0e16' },
})
monaco.editor.setTheme('bonsai-dark')When the context changes, keep autocomplete in sync with ac.setContext(context) so completions reflect the available variables.