diff --git a/apps/overview.md b/apps/overview.md
index d03b08e75..2963756ff 100644
--- a/apps/overview.md
+++ b/apps/overview.md
@@ -44,11 +44,11 @@
- see [package.json](./vscode/package.json) for declaration of commands
- see [main.ts](./vscode/src/main.ts) for registration of command
-- Look for "behaviors" in ProseMirror, CodeMirror
+- Look for "behaviors" in ProseMirror, Monaco (`packages/editor-monaco`; the previous CodeMirror implementation is kept in `packages/editor-codemirror`)
- arrow keys, ctrl+z, mouse click, etc.
- [commands in Ace](packages/editor/src/optional/ace/ace.ts)
- - used instead of CodeMirror for code cells in the Visual Editor in RStudio
+ - used instead of Monaco for code cells in the Visual Editor in RStudio
## Communication boundaries
@@ -129,20 +129,15 @@ which passes through `VisualEditorPovider`, `visualEditorClient`,
### Example: Getting diagnostics for YAML front matter
For example in
-[diagnostics.ts](../packages/editor-codemirror/src/behaviors/diagnostics.ts)
+[diagnostics.ts](../packages/editor-monaco/src/behaviors/diagnostics.ts)
```ts
const diagnostics = await getDiagnostics(cellContext, behaviorContext);
-if (!diagnostics) return;
-
-for (const error of diagnostics) {
- underline(
- cmView,
- rowColumnToIndex(code, [error[kStartColumn], error[kStartRow]]),
- rowColumnToIndex(code, [error[kEndColumn], error[kEndRow]]),
- error.text,
- );
+const model = editor.getModel();
+if (id !== requestId || !model || model.isDisposed()) {
+ return;
}
+setMarkers(model, diagnostics || []);
```
which passes through
diff --git a/apps/vscode-editor/package.json b/apps/vscode-editor/package.json
index 1c5a76de2..4f2e080b3 100644
--- a/apps/vscode-editor/package.json
+++ b/apps/vscode-editor/package.json
@@ -24,6 +24,7 @@
"core-browser": "*",
"editor": "*",
"editor-codemirror": "*",
+ "editor-monaco": "*",
"editor-core": "*",
"editor-types": "*",
"editor-ui": "*",
diff --git a/apps/vscode/package.json b/apps/vscode/package.json
index 093c923ea..b4cf5eec6 100644
--- a/apps/vscode/package.json
+++ b/apps/vscode/package.json
@@ -1528,6 +1528,7 @@
"test-positron": "npm run build-test && node ./scripts/run-positron-tests.mjs"
},
"dependencies": {
+ "jsonc-parser": "^3.3.1",
"axios": "^1.20.0",
"core": "*",
"core-node": "*",
diff --git a/apps/vscode/src/providers/editor/codeview.ts b/apps/vscode/src/providers/editor/codeview.ts
index b639fdd0b..a91f20e2d 100644
--- a/apps/vscode/src/providers/editor/codeview.ts
+++ b/apps/vscode/src/providers/editor/codeview.ts
@@ -42,6 +42,7 @@ import { embeddedLanguage } from "../../vdoc/languages";
import { virtualDocForCode } from "../../vdoc/vdoc";
import { vdocCompletions } from "../../vdoc/vdoc-completion";
import { MarkdownEngine } from "../../markdown/engine";
+import { activeColorTheme, contributedGrammar, contributedLanguages, editorFont } from "./theme";
/**
* In the host, this initializes functions that can be called by the client (the visual editor) for doing code cell things.
@@ -117,6 +118,18 @@ export function vscodeCodeViewServer(_engine: MarkdownEngine, document: TextDocu
isIncomplete: false
};
},
+ async codeViewColorTheme(themeId?: string) {
+ return activeColorTheme(themeId);
+ },
+ async codeViewLanguages() {
+ return contributedLanguages();
+ },
+ async codeViewGrammar(scopeName: string) {
+ return contributedGrammar(scopeName);
+ },
+ async codeViewEditorFont() {
+ return editorFont(document);
+ },
async codeViewPreviewDiagram(state: DiagramState, activate: boolean) {
commands.executeCommand("quarto.previewDiagram", { state, activate });
}
diff --git a/apps/vscode/src/providers/editor/editor.ts b/apps/vscode/src/providers/editor/editor.ts
index 0aba15da2..f8cecd30f 100644
--- a/apps/vscode/src/providers/editor/editor.ts
+++ b/apps/vscode/src/providers/editor/editor.ts
@@ -625,7 +625,7 @@ export class VisualEditorProvider implements CustomTextEditorProvider {
-
+
diff --git a/apps/vscode/src/providers/editor/prefs.ts b/apps/vscode/src/providers/editor/prefs.ts
index 374aefbab..3667fc40b 100644
--- a/apps/vscode/src/providers/editor/prefs.ts
+++ b/apps/vscode/src/providers/editor/prefs.ts
@@ -43,6 +43,11 @@ const kQuartoEditorMarkdownReferences = "quarto.visualEditor.markdownReferences"
const kQuartoEditorMarkdownReferenceLinks = "quarto.visualEditor.markdownReferenceLinks";
const kMonitoredConfigurations = [
+ "editor.fontFamily",
+ "editor.fontWeight",
+ "editor.fontLigatures",
+ "editor.lineHeight",
+ "editor.letterSpacing",
kEditorAutoClosingBrackets,
kEditorRenderWhitespace,
kEditorInsertSpaces,
diff --git a/apps/vscode/src/providers/editor/theme.ts b/apps/vscode/src/providers/editor/theme.ts
new file mode 100644
index 000000000..aba53c060
--- /dev/null
+++ b/apps/vscode/src/providers/editor/theme.ts
@@ -0,0 +1,304 @@
+/*
+ * theme.ts
+ *
+ * Copyright (C) 2026 by Posit Software, PBC
+ *
+ * Resolve the active VS Code color theme and the languages/grammars
+ * contributed by installed extensions so that the visual editor's code
+ * cells can be highlighted exactly as the source editor would be.
+ *
+ * Reference: https://github.com/quarto-dev/quarto/pull/772
+ * Reference: https://github.com/microsoft/vscode/issues/32813#issuecomment-524174937
+ */
+
+import path from "node:path";
+import { readFileSync } from "node:fs";
+
+// (esm entry point: the umd one uses a dynamic require that esbuild can't bundle)
+import { parse as parseJsonc } from "jsonc-parser/lib/esm/main.js";
+
+import { ColorThemeKind, extensions, TextDocument, window, workspace } from "vscode";
+
+import {
+ CodeViewColorTheme,
+ CodeViewEditorFont,
+ CodeViewGrammar,
+ CodeViewLanguage,
+ CodeViewTokenColor
+} from "editor-types";
+
+interface ThemeContribution {
+ id?: string;
+ label?: string;
+ uiTheme?: string;
+ path: string;
+}
+
+interface GrammarContribution {
+ language?: string;
+ scopeName: string;
+ path: string;
+}
+
+interface LanguageContribution {
+ id: string;
+ aliases?: string[];
+ extensions?: string[];
+ configuration?: string;
+}
+
+interface ThemeJson {
+ include?: string;
+ colors?: Record;
+ tokenColors?: CodeViewTokenColor[] | string;
+}
+
+/**
+ * Candidate names for the active color theme. When vscode auto-detects the
+ * OS color scheme the active theme is one of the preferred*ColorTheme
+ * settings rather than workbench.colorTheme, so we try those first.
+ */
+export function activeColorThemeNames(): string[] {
+ const workbench = workspace.getConfiguration("workbench");
+ const win = workspace.getConfiguration("window");
+ const names: Array = [];
+ const kind = window.activeColorTheme.kind;
+ if (win.get("autoDetectHighContrast", true)) {
+ if (kind === ColorThemeKind.HighContrast) {
+ names.push(workbench.get("preferredHighContrastColorTheme"));
+ } else if (kind === ColorThemeKind.HighContrastLight) {
+ names.push(workbench.get("preferredHighContrastLightColorTheme"));
+ }
+ }
+ if (win.get("autoDetectColorScheme", false)) {
+ if (kind === ColorThemeKind.Dark) {
+ names.push(workbench.get("preferredDarkColorTheme"));
+ } else if (kind === ColorThemeKind.Light) {
+ names.push(workbench.get("preferredLightColorTheme"));
+ }
+ }
+ names.push(workbench.get("colorTheme"));
+ return names.filter((name): name is string => !!name);
+}
+
+/**
+ * Resolve the active color theme to its colors and token color rules (with
+ * included themes merged in). Returns undefined if the theme can't be found
+ * or isn't a json theme. `themeId` is the settings id of the theme currently
+ * applied to the webview (if known).
+ */
+export function activeColorTheme(themeId?: string): CodeViewColorTheme | undefined {
+ const activeKind = kindToType(window.activeColorTheme.kind);
+ // the webview knows the id of the theme it is currently displayed with
+ // (which during theme preview differs from the workbench.colorTheme setting)
+ const names = themeId ? [themeId, ...activeColorThemeNames()] : activeColorThemeNames();
+ const candidates = names
+ .map(name => ({ name, contribution: findThemeContribution(name) }))
+ .filter(candidate => !!candidate.contribution);
+ if (candidates.length === 0) {
+ return undefined;
+ }
+
+ // prefer a theme whose kind matches the active kind
+ const candidate = candidates.find(candidate =>
+ themeType(candidate.contribution!.theme.uiTheme) === activeKind
+ ) || candidates[0];
+ const { name, contribution } = candidate;
+
+ const themePath = path.join(contribution!.extensionPath, contribution!.theme.path);
+ if (path.extname(themePath).toLowerCase() !== ".json") {
+ // .tmTheme (plist) themes aren't supported
+ return undefined;
+ }
+
+ const theme = loadThemeJson(themePath, new Set());
+ if (!theme) {
+ return undefined;
+ }
+
+ return {
+ name,
+ type: themeType(contribution!.theme.uiTheme),
+ colors: theme.colors,
+ tokenColors: theme.tokenColors
+ };
+}
+
+/**
+ * The source editor's font settings (so code cells can use the same font)
+ */
+export function editorFont(document: TextDocument): CodeViewEditorFont {
+ const editor = workspace.getConfiguration("editor", document.uri);
+ return {
+ fontFamily: editor.get("fontFamily", "Menlo, Monaco, 'Courier New', monospace"),
+ fontSize: editor.get("fontSize", 12),
+ fontWeight: String(editor.get("fontWeight", "normal")),
+ fontLigatures: editor.get("fontLigatures", false),
+ lineHeight: editor.get("lineHeight", 0),
+ letterSpacing: editor.get("letterSpacing", 0)
+ };
+}
+
+/**
+ * Languages contributed by installed extensions (including VS Code's built-in
+ * language extensions), with the scope of the grammar for each language.
+ */
+export function contributedLanguages(): CodeViewLanguage[] {
+ const languages = new Map();
+
+ // languages (an id can be contributed by multiple extensions; merge them)
+ for (const extension of extensions.all) {
+ const contributes = extension.packageJSON?.contributes;
+ const langs = contributes?.languages as LanguageContribution[] | undefined;
+ for (const lang of langs || []) {
+ if (!lang.id) {
+ continue;
+ }
+ const language = languages.get(lang.id) || { id: lang.id, aliases: [], extensions: [] };
+ language.aliases = unique([...language.aliases, ...(lang.aliases || [])]);
+ language.extensions = unique([...language.extensions, ...(lang.extensions || [])]);
+ if (lang.configuration && language.configuration === undefined) {
+ const configPath = path.join(extension.extensionPath, lang.configuration);
+ language.configuration = loadJsonc(configPath);
+ }
+ languages.set(lang.id, language);
+ }
+ }
+
+ // grammars (first grammar contributed for a language wins, which is also
+ // what vscode does)
+ for (const extension of extensions.all) {
+ const grammars = extension.packageJSON?.contributes?.grammars as GrammarContribution[] | undefined;
+ for (const grammar of grammars || []) {
+ if (grammar.language && grammar.scopeName) {
+ const language = languages.get(grammar.language);
+ if (language && !language.scopeName) {
+ language.scopeName = grammar.scopeName;
+ }
+ }
+ }
+ }
+
+ return Array.from(languages.values());
+}
+
+/**
+ * The raw contents of the grammar with the given scope name (undefined if no
+ * installed extension contributes a grammar for the scope)
+ */
+export function contributedGrammar(scopeName: string): CodeViewGrammar | undefined {
+ for (const extension of extensions.all) {
+ const grammars = extension.packageJSON?.contributes?.grammars as GrammarContribution[] | undefined;
+ for (const grammar of grammars || []) {
+ if (grammar.scopeName === scopeName) {
+ const grammarPath = path.join(extension.extensionPath, grammar.path);
+ try {
+ return {
+ scopeName,
+ path: grammarPath,
+ content: readFileSync(grammarPath, "utf8")
+ };
+ } catch {
+ // fall through to other contributions
+ }
+ }
+ }
+ }
+ return undefined;
+}
+
+function findThemeContribution(name: string) {
+ for (const extension of extensions.all) {
+ const themes = extension.packageJSON?.contributes?.themes as ThemeContribution[] | undefined;
+ const theme = themes?.find(theme => themeNameMatches(theme, name));
+ if (theme) {
+ return { extensionPath: extension.extensionPath, theme };
+ }
+ }
+ return undefined;
+}
+
+// vscode's settings id for a theme is its id (or label if it has no id);
+// built-in themes are also addressable with a "Default " prefix
+function themeNameMatches(theme: ThemeContribution, name: string) {
+ const candidates = [theme.id, theme.label].filter((value): value is string => !!value);
+ return candidates.some(candidate =>
+ candidate === name ||
+ `Default ${candidate}` === name ||
+ candidate === name.replace(/^Default /, "")
+ );
+}
+
+function kindToType(kind: ColorThemeKind): CodeViewColorTheme["type"] {
+ switch (kind) {
+ case ColorThemeKind.Light:
+ return "light";
+ case ColorThemeKind.HighContrast:
+ return "hcDark";
+ case ColorThemeKind.HighContrastLight:
+ return "hcLight";
+ default:
+ return "dark";
+ }
+}
+
+function themeType(uiTheme?: string): CodeViewColorTheme["type"] {
+ switch (uiTheme) {
+ case "vs":
+ return "light";
+ case "vs-dark":
+ return "dark";
+ case "hc-black":
+ return "hcDark";
+ case "hc-light":
+ return "hcLight";
+ default:
+ return kindToType(window.activeColorTheme.kind);
+ }
+}
+
+// load a theme and the themes it includes (included themes are applied
+// first so the including theme's rules take precedence)
+function loadThemeJson(themePath: string, visited: Set)
+ : { colors: Record, tokenColors: CodeViewTokenColor[] } | undefined {
+
+ if (visited.has(themePath)) {
+ return undefined;
+ }
+ visited.add(themePath);
+
+ const theme = loadJsonc(themePath) as ThemeJson | undefined;
+ if (!theme) {
+ return undefined;
+ }
+
+ let colors: Record = {};
+ let tokenColors: CodeViewTokenColor[] = [];
+
+ if (theme.include) {
+ const included = loadThemeJson(path.join(path.dirname(themePath), theme.include), visited);
+ if (included) {
+ colors = included.colors;
+ tokenColors = included.tokenColors;
+ }
+ }
+
+ colors = { ...colors, ...(theme.colors || {}) };
+ if (Array.isArray(theme.tokenColors)) {
+ tokenColors = [...tokenColors, ...theme.tokenColors];
+ }
+
+ return { colors, tokenColors };
+}
+
+function loadJsonc(filePath: string): unknown {
+ try {
+ return parseJsonc(readFileSync(filePath, "utf8"));
+ } catch {
+ return undefined;
+ }
+}
+
+function unique(values: string[]) {
+ return Array.from(new Set(values));
+}
diff --git a/package-lock.json b/package-lock.json
index 4c080fba0..df4ab913d 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -116,6 +116,7 @@
"glob": "^13.0.6",
"highlight.js": "^11.12.0",
"js-yaml": "^5.5.0",
+ "jsonc-parser": "^3.3.1",
"lodash.debounce": "^4.0.8",
"lodash.throttle": "^4.1.1",
"markdown-it": "^15.0.3",
@@ -173,6 +174,7 @@
"editor": "*",
"editor-codemirror": "*",
"editor-core": "*",
+ "editor-monaco": "*",
"editor-types": "*",
"editor-ui": "*",
"lodash.debounce": "^4.0.8",
@@ -3706,9 +3708,9 @@
"license": "MIT"
},
"node_modules/@lezer/common": {
- "version": "1.5.3",
- "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.3.tgz",
- "integrity": "sha512-H0iErY4e43LpXbYDyBci5W4v/RwTgGV3YzYOlJPiGZ8RY8w51kVE+xLn1tg3Z6UmSfyETvtsUK3r7YsgDQEI7Q==",
+ "version": "1.5.5",
+ "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.5.tgz",
+ "integrity": "sha512-Pw7PVCndruF1pb3ebCxpSVpkMHSDjwRCLcTRw+RizUKco5QbWylwq2g4lnG/RYN7yTTQDXE9F77MwtkAyphrjg==",
"license": "MIT"
},
"node_modules/@lezer/cpp": {
@@ -3723,9 +3725,9 @@
}
},
"node_modules/@lezer/css": {
- "version": "1.3.8",
- "resolved": "https://registry.npmjs.org/@lezer/css/-/css-1.3.8.tgz",
- "integrity": "sha512-EJn1zcL9qoDptief6ipWKZKLiOpXkxSe0+t8CH9oiMVcZlq7NBWrjCqnc/41EIjeo/ITj1gFFiATdTkaJDL+Og==",
+ "version": "1.3.9",
+ "resolved": "https://registry.npmjs.org/@lezer/css/-/css-1.3.9.tgz",
+ "integrity": "sha512-ZmzgvTOBdJWGIsY/ItAfN7UW8DV802oZG+jeG6E508SwEzz5YhuAajptpWPICvIyqYAD2+VjMdOS40GgGkyEOQ==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
@@ -3754,9 +3756,9 @@
}
},
"node_modules/@lezer/java": {
- "version": "1.1.4",
- "resolved": "https://registry.npmjs.org/@lezer/java/-/java-1.1.4.tgz",
- "integrity": "sha512-gQylJ2xHV2xXsVqtnAcJrcROvLZJaQiC0tR//+q0k961BxqvyYttusPkJ8d9T1fPDJKHJW2etUH8XbIn9jhVUg==",
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@lezer/java/-/java-1.1.5.tgz",
+ "integrity": "sha512-JppTd1iKHpeNlLYw/Zl8JX7zSY5HqAzK4zZ05lpn8l8Sk7Fo5KAKiDiMiezKoT5lfeC2xZf8HeKp1tSctj4G6Q==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
@@ -3765,9 +3767,9 @@
}
},
"node_modules/@lezer/javascript": {
- "version": "1.5.5",
- "resolved": "https://registry.npmjs.org/@lezer/javascript/-/javascript-1.5.5.tgz",
- "integrity": "sha512-sWg4yX1J6XW67AaAynVt0iwF0M5c+np36TEu+P2ifAJ8haRYvHnWDV28r1jdwnJehWCwXECutAUy56K4RBZIyg==",
+ "version": "1.5.6",
+ "resolved": "https://registry.npmjs.org/@lezer/javascript/-/javascript-1.5.6.tgz",
+ "integrity": "sha512-sEvOqpl4vJQca0NugSQK02vQPbc5KrvoojnYGoqqFgFO0MtcYhwGiHKVEB+LuXYJnfyXo4QPA8ztbrEa4Fw5NA==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
@@ -3797,18 +3799,18 @@
}
},
"node_modules/@lezer/lr": {
- "version": "1.4.10",
- "resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.10.tgz",
- "integrity": "sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==",
+ "version": "1.4.12",
+ "resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.12.tgz",
+ "integrity": "sha512-On9HU4oMMiHPMkQDgoTnGahjgG5n/MkN38CgG77sMraKLL+3JBbeQNi1FcCXV9QRqwF9/BUXP3caHyJC1fBOTQ==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.0.0"
}
},
"node_modules/@lezer/markdown": {
- "version": "1.7.2",
- "resolved": "https://registry.npmjs.org/@lezer/markdown/-/markdown-1.7.2.tgz",
- "integrity": "sha512-iTkYvoVcKt3WkeL7qUDyXHONZEwLio4wj8KTNi2dnjQEXBZKMV63BpQrPqfsM+OkvuRbiSTAcycYAsQzLhRNoQ==",
+ "version": "1.8.1",
+ "resolved": "https://registry.npmjs.org/@lezer/markdown/-/markdown-1.8.1.tgz",
+ "integrity": "sha512-M5YmEcKWO4mJ45Y1hECwqyu8cLv25EiXOK554PVOvDOWOP0olwelHHQUWr9sjF+d1bENVhy0nfJ8E57XZUTiXg==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.5.0",
@@ -8060,6 +8062,10 @@
"resolved": "packages/editor-core",
"link": true
},
+ "node_modules/editor-monaco": {
+ "resolved": "packages/editor-monaco",
+ "link": true
+ },
"node_modules/editor-server": {
"resolved": "packages/editor-server",
"link": true
@@ -9428,7 +9434,6 @@
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz",
"integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==",
- "dev": true,
"license": "MIT"
},
"node_modules/jsondiffpatch": {
@@ -10579,6 +10584,37 @@
"node": ">=12"
}
},
+ "node_modules/monaco-editor": {
+ "version": "0.57.0",
+ "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.57.0.tgz",
+ "integrity": "sha512-5BkI9KGoqrNvBGUe15/QlZq3OooZ8WLg1AxTpaqHRCP3HNpzPPZKE2EDz8M7c+VRmCeUw1Brp4cx/PWm3kI/5A==",
+ "license": "MIT",
+ "dependencies": {
+ "dompurify": "3.4.15",
+ "marked": "14.0.0"
+ }
+ },
+ "node_modules/monaco-editor/node_modules/dompurify": {
+ "version": "3.4.15",
+ "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.15.tgz",
+ "integrity": "sha512-EUBjM+B+lkDE41iE82DDSCfkoPGfXx8IxFxPMjNzm/Uk4xDet77rTN9wqlxlVg71kK7XGuUMv6wUxJUwwv+Xyw==",
+ "license": "(MPL-2.0 OR Apache-2.0)",
+ "optionalDependencies": {
+ "@types/trusted-types": "^2.0.7"
+ }
+ },
+ "node_modules/monaco-editor/node_modules/marked": {
+ "version": "14.0.0",
+ "resolved": "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz",
+ "integrity": "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==",
+ "license": "MIT",
+ "bin": {
+ "marked": "bin/marked.js"
+ },
+ "engines": {
+ "node": ">= 18"
+ }
+ },
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -12971,6 +13007,18 @@
"integrity": "sha512-nQsacoiijvl0fRf2q28uFDrAVwRhdCa3QveiF7/CX0r18mLR5FU/1WskSsCKOjmEbtteKUnY8x8Yzxop+k0zwg==",
"license": "MIT"
},
+ "node_modules/vscode-oniguruma": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/vscode-oniguruma/-/vscode-oniguruma-2.0.1.tgz",
+ "integrity": "sha512-poJU8iHIWnC3vgphJnrLZyI3YdqRlR27xzqDmpPXYzA93R4Gk8z7T6oqDzDoHjoikA2aS82crdXFkjELCdJsjQ==",
+ "license": "MIT"
+ },
+ "node_modules/vscode-textmate": {
+ "version": "9.3.2",
+ "resolved": "https://registry.npmjs.org/vscode-textmate/-/vscode-textmate-9.3.2.tgz",
+ "integrity": "sha512-n2uGbUcrjhUEBH16uGA0TvUfhWwliFZ1e3+pTjrkim1Mt7ydB41lV08aUvsi70OlzDWp6X7Bx3w/x3fAXIsN0Q==",
+ "license": "MIT"
+ },
"node_modules/vscode-uri": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.2.0.tgz",
@@ -13617,6 +13665,32 @@
"eslint-config-custom": "*"
}
},
+ "packages/editor-monaco": {
+ "version": "0.1.0",
+ "license": "MIT",
+ "dependencies": {
+ "core": "*",
+ "editor": "*",
+ "editor-core": "*",
+ "editor-types": "*",
+ "lodash.debounce": "^4.0.8",
+ "monaco-editor": "^0.57.0",
+ "prosemirror-commands": "^1.7.2",
+ "prosemirror-history": "^1.5.1",
+ "prosemirror-model": "^1.25.12",
+ "prosemirror-state": "^1.4.4",
+ "prosemirror-view": "^1.42.6",
+ "tsconfig": "*",
+ "vscode-languageserver-types": "^3.18.4",
+ "vscode-oniguruma": "^2.0.1",
+ "vscode-textmate": "^9.3.2"
+ },
+ "devDependencies": {
+ "@types/lodash.debounce": "^4.0.9",
+ "eslint": "^10.13.0",
+ "eslint-config-custom": "*"
+ }
+ },
"packages/editor-server": {
"version": "0.1.0",
"license": "MIT",
@@ -13677,6 +13751,7 @@
"editor": "*",
"editor-codemirror": "*",
"editor-core": "*",
+ "editor-monaco": "*",
"editor-types": "*",
"i18next": "^26.5.4",
"react": "^19.3.1",
diff --git a/packages/editor-core/src/services.ts b/packages/editor-core/src/services.ts
index e467c81bf..9cc6ba253 100644
--- a/packages/editor-core/src/services.ts
+++ b/packages/editor-core/src/services.ts
@@ -39,7 +39,11 @@ import {
CodeViewActiveBlockContext,
DiagramState,
CodeViewCellContext,
- kCodeViewGetDiagnostics
+ kCodeViewGetDiagnostics,
+ kCodeViewGetColorTheme,
+ kCodeViewGetLanguages,
+ kCodeViewGetGrammar,
+ kCodeViewGetEditorFont
} from "editor-types";
@@ -87,6 +91,18 @@ export function editorCodeViewJsonRpcServer(request: JsonRpcRequestTransport): C
codeViewPreviewDiagram(state: DiagramState, activate: boolean) {
return request(kCodeViewPreviewDiagram, [state, activate]);
},
+ codeViewColorTheme(themeId?: string) {
+ return request(kCodeViewGetColorTheme, [themeId]);
+ },
+ codeViewLanguages() {
+ return request(kCodeViewGetLanguages, []);
+ },
+ codeViewGrammar(scopeName: string) {
+ return request(kCodeViewGetGrammar, [scopeName]);
+ },
+ codeViewEditorFont() {
+ return request(kCodeViewGetEditorFont, []);
+ },
};
}
diff --git a/packages/editor-monaco/eslint.config.mjs b/packages/editor-monaco/eslint.config.mjs
new file mode 100644
index 000000000..6484834b7
--- /dev/null
+++ b/packages/editor-monaco/eslint.config.mjs
@@ -0,0 +1 @@
+export { default } from "eslint-config-custom";
diff --git a/packages/editor-monaco/package.json b/packages/editor-monaco/package.json
new file mode 100644
index 000000000..9d0826482
--- /dev/null
+++ b/packages/editor-monaco/package.json
@@ -0,0 +1,33 @@
+{
+ "name": "editor-monaco",
+ "version": "0.1.0",
+ "private": true,
+ "license": "MIT",
+ "main": "./src/index.ts",
+ "types": "./src/index.ts",
+ "dependencies": {
+ "core": "*",
+ "editor": "*",
+ "editor-core": "*",
+ "editor-types": "*",
+ "lodash.debounce": "^4.0.8",
+ "monaco-editor": "^0.57.0",
+ "vscode-oniguruma": "^2.0.1",
+ "vscode-textmate": "^9.3.2",
+ "prosemirror-commands": "^1.7.2",
+ "prosemirror-history": "^1.5.1",
+ "prosemirror-model": "^1.25.12",
+ "prosemirror-state": "^1.4.4",
+ "prosemirror-view": "^1.42.6",
+ "tsconfig": "*",
+ "vscode-languageserver-types": "^3.18.4"
+ },
+ "devDependencies": {
+ "@types/lodash.debounce": "^4.0.9",
+ "eslint": "^10.13.0",
+ "eslint-config-custom": "*"
+ },
+ "scripts": {
+ "lint": "eslint src"
+ }
+}
diff --git a/packages/editor-monaco/src/behaviors/completion.ts b/packages/editor-monaco/src/behaviors/completion.ts
new file mode 100644
index 000000000..27beed5ce
--- /dev/null
+++ b/packages/editor-monaco/src/behaviors/completion.ts
@@ -0,0 +1,360 @@
+/*
+ * completion.ts
+ *
+ * Copyright (C) 2026 by Posit Software, PBC
+ *
+ * Completions for code cells (provided by the host via the code view server).
+ * Monaco completion providers are global (per language), so a single provider
+ * is registered for all languages and dispatches to the code view that owns
+ * the model being completed.
+ */
+
+import {
+ CompletionItem,
+ CompletionItemKind,
+ InsertReplaceEdit,
+ InsertTextFormat,
+ MarkupContent,
+ MarkupKind,
+ TextEdit
+} from "vscode-languageserver-types";
+
+import { editorLanguage } from "editor-core";
+
+import { CodeViewCompletionContext, codeViewCompletionContext } from "editor";
+
+import { monaco } from "../monaco";
+
+import { Behavior, BehaviorContext } from ".";
+import { escapeRegExpCharacters } from "core";
+
+// code views registered for completion (keyed by model uri)
+const codeViews = new Map();
+
+// set while an explicit (ctrl+space) completion request is in flight
+let explicitRequest = false;
+
+let providerRegistered = false;
+
+export function completionBehavior(behaviorContext: BehaviorContext): Behavior {
+
+ // don't provide behavior if we don't have completions
+ if (!behaviorContext.pmContext.ui.codeview) {
+ return {};
+ }
+
+ registerProvider();
+
+ let modelUri: string | undefined;
+
+ return {
+ init(_pmNode, editor) {
+ const model = editor.getModel();
+ if (model) {
+ modelUri = model.uri.toString();
+ codeViews.set(modelUri, behaviorContext);
+ }
+
+ // explicit completion request (so we can distinguish it from quick suggestions)
+ editor.addAction({
+ id: "quarto.codeview.triggerSuggest",
+ label: "Trigger Suggest",
+ keybindings: [monaco.KeyMod.CtrlCmd | monaco.KeyCode.Space],
+ run: (ed) => {
+ explicitRequest = true;
+ ed.trigger("keyboard", "editor.action.triggerSuggest", {});
+ }
+ });
+ },
+ cleanup() {
+ if (modelUri) {
+ codeViews.delete(modelUri);
+ }
+ }
+ };
+}
+
+function registerProvider() {
+ if (providerRegistered) {
+ return;
+ }
+ providerRegistered = true;
+
+ monaco.languages.registerCompletionItemProvider("*", {
+ triggerCharacters: [".", "$", "@", ":", ">", "/"],
+ provideCompletionItems: async (model, position, _context, token) => {
+
+ // was this an explicit request?
+ const explicit = explicitRequest;
+ explicitRequest = false;
+
+ // find the code view that owns this model
+ const behaviorContext = codeViews.get(model.uri.toString());
+ if (!behaviorContext) {
+ return null;
+ }
+
+ // no completions if there is no path
+ const filepath = behaviorContext.pmContext.ui.context.getDocumentPath();
+ if (!filepath) {
+ return null;
+ }
+
+ // see if there is a completion context
+ const cvContext = codeViewCompletionContext(filepath, behaviorContext.view.state, explicit);
+ if (!cvContext) {
+ return null;
+ }
+
+ // check if this is a known editor language
+ const language = editorLanguage(cvContext.language);
+ if (!language) {
+ return null;
+ }
+
+ // if we don't have quick suggestions enabled and this isn't explicit then bail
+ if (!behaviorContext.pmContext.ui.prefs.quickSuggestions() && !explicit) {
+ return null;
+ }
+
+ // text on the line before the cursor
+ const textBefore = model.getValueInRange(new monaco.Range(
+ position.lineNumber, 1, position.lineNumber, position.column
+ ));
+
+ // if we aren't explicit then filter based on match (letter + wordchar + optional trigger chars)
+ if (!explicit) {
+ const trigger = (language.trigger || ["."]);
+ const regex = new RegExp('(^|[ \t])[\\\\A-Za-z_\\.][\\w_\\(\\)\\[\\]' + escapeRegExpCharacters(trigger.join('')) + ']*$');
+ if (!textBefore.match(regex)) {
+ return null;
+ }
+ }
+
+ // get completions
+ if (token.isCancellationRequested) {
+ return null;
+ }
+ return getCompletions(model, position, textBefore, cvContext, behaviorContext);
+ }
+ });
+}
+
+const compareBySortText = (a: CompletionItem, b: CompletionItem) => {
+ if (a.sortText && b.sortText) {
+ return a.sortText.localeCompare(b.sortText);
+ } else {
+ return 0;
+ }
+};
+
+// compute the column the completion replaces from (the cursor column less the
+// length of the text the item's edit replaces)
+const itemFromColumn = (item: CompletionItem, cursorColumn: number) => {
+ return item.textEdit
+ ? InsertReplaceEdit.is(item.textEdit)
+ ? cursorColumn - (item.textEdit.insert.end.character - item.textEdit.insert.start.character)
+ : TextEdit.is(item.textEdit)
+ ? cursorColumn - (item.textEdit.range.end.character - item.textEdit.range.start.character)
+ : cursorColumn
+ : cursorColumn;
+};
+
+/**
+ * replaceText for a given CompletionItem is the text that is already in the document
+ * that that CompletionItem will replace.
+ *
+ * Example 1: if you are typing `lib` and get the completion `library`, then this function
+ * will give `lib`.
+ * Example 2: if you are typing `os.a` and get the completion `abc`, then this function
+ * will give `a`.
+ */
+const getReplaceText = (textBefore: string, item: CompletionItem, cursorColumn: number) =>
+ textBefore.slice(itemFromColumn(item, cursorColumn) - 1);
+
+const sortTextItemsBoostScore = (textBefore: string, cursorColumn: number, items: CompletionItem[], index: number) => {
+ const total = items.length;
+ const item = items[index];
+ // compute replaceText
+ const replaceText = getReplaceText(textBefore, item, cursorColumn);
+
+ // if the replaceText doesn't start with "." then bury items that do
+ if (!replaceText.startsWith(".") && item.label.startsWith(".")) {
+ return -99;
+ }
+
+ // only boost things that have a prefix match
+ if (item.label.toLowerCase().startsWith(replaceText) ||
+ (item.textEdit && item.textEdit.newText.toLowerCase().startsWith(replaceText)) ||
+ (item.insertText && item.insertText.toLowerCase().startsWith(replaceText))) {
+ return -99 + Math.round(((total - index) / total) * 198);
+ } else {
+ return -99;
+ }
+};
+
+const defaultBoostScore = (textBefore: string, cursorColumn: number, items: CompletionItem[], index: number) => {
+ const item = items[index];
+
+ const replaceText = getReplaceText(textBefore, item, cursorColumn);
+
+ // if you haven't typed into the completions yet (for example after a `.`) then
+ // score items starting with non-alphabetic characters -1, everything else 0.
+ if (replaceText.length === 0) return isLetter(item.label[0]) ? 0 : -1;
+
+ // We filter items by replaceText inclusion before scoring,
+ // so i is guaranteed to be an index into `item.label`...
+ const i = item.label.toLowerCase().indexOf(replaceText.toLowerCase());
+ // and `replaceTextInItemLabel` should be the same as `replaceText` up to upper/lowercase
+ // differences.
+ const replaceTextInItemLabel = item.label.slice(i, replaceText.length);
+
+ // mostly counts how many upper/lowercase differences there are
+ const diff = simpleStringDiff(replaceTextInItemLabel, replaceText);
+
+ // `-i` scores completions better if what you typed is earlier in the completion
+ // `-diff/10` mostly tie breaks that score by capitalization differences.
+ return -i - diff / 10; // 10 is a magic number
+};
+
+async function getCompletions(
+ model: monaco.editor.ITextModel,
+ position: monaco.Position,
+ textBefore: string,
+ cvContext: CodeViewCompletionContext,
+ behaviorContext: BehaviorContext
+): Promise {
+
+ // get completions
+ const completions = await behaviorContext.pmContext.ui.codeview?.codeViewCompletions(cvContext);
+ if (completions === undefined) return null;
+ if (completions.items.length == 0) return null;
+
+ const itemsHaveSortText = completions.items?.[0].sortText !== undefined;
+
+ const items = itemsHaveSortText ?
+ completions.items.sort(compareBySortText) :
+ completions.items;
+
+ // The token is the contents of the line up to your cursor.
+ // For example, if you type `os.a` then token will be `os.a`.
+ // Note: in contrast, when you type `os.a` replaceText will give `a` for a completion like `abc`.
+ const token = textBefore.match(/\S+$/)?.[0];
+
+ const cursorColumn = position.column;
+
+ const filteredItems = items.filter(item => {
+ // no text completions that aren't snippets
+ if (item.kind === CompletionItemKind.Text &&
+ item.insertTextFormat !== InsertTextFormat.Snippet) return false;
+
+ // only allow non-text edits if we have no token
+ if (item.textEdit === undefined && token) return false;
+
+ // require at least inclusion
+ const replaceText = getReplaceText(textBefore, item, cursorColumn).toLowerCase();
+ return item.label.toLowerCase().includes(replaceText) ||
+ item.insertText?.toLowerCase().includes(replaceText);
+ });
+
+ const boostScore = itemsHaveSortText ?
+ sortTextItemsBoostScore :
+ defaultBoostScore;
+
+ // compute boosts and sort by them (highest first); monaco sorts by sortText
+ // so encode the resulting rank in the sortText
+ const scored = filteredItems
+ .map((item, index) => ({ item, boost: boostScore(textBefore, cursorColumn, filteredItems, index), index }))
+ .sort((a, b) => (b.boost - a.boost) || (a.index - b.index));
+
+ const suggestions = scored.map(({ item }, rank): monaco.languages.CompletionItem => {
+ const from = itemFromColumn(item, cursorColumn);
+ const insertText = item.textEdit?.newText ?? (item.insertText || item.label);
+ const replaceText = getReplaceText(textBefore, item, cursorColumn);
+ return {
+ label: item.label,
+ kind: lsKindToMonacoKind(item.kind),
+ detail: item.detail,
+ documentation: documentationForItem(item),
+ insertText,
+ insertTextRules: item.insertTextFormat === InsertTextFormat.Snippet
+ ? monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet
+ : monaco.languages.CompletionItemInsertTextRule.None,
+ range: new monaco.Range(position.lineNumber, from, position.lineNumber, cursorColumn),
+ sortText: String(rank).padStart(5, "0"),
+ // our own filtering/ranking has already been applied, so make sure
+ // monaco's filter keeps everything (it matches against filterText)
+ filterText: replaceText + item.label,
+ command: item.command?.command === "editor.action.triggerSuggest"
+ ? { id: "editor.action.triggerSuggest", title: "" }
+ : undefined
+ };
+ });
+
+ // make sure the model is still the same (cell could have been deleted)
+ if (model.isDisposed()) {
+ return null;
+ }
+
+ return { suggestions };
+}
+
+function lsKindToMonacoKind(kind?: CompletionItemKind): monaco.languages.CompletionItemKind {
+ const kinds = monaco.languages.CompletionItemKind;
+ switch (kind || CompletionItemKind.Text) {
+ case CompletionItemKind.Method: return kinds.Method;
+ case CompletionItemKind.Function: return kinds.Function;
+ case CompletionItemKind.Constructor: return kinds.Constructor;
+ case CompletionItemKind.Field: return kinds.Field;
+ case CompletionItemKind.Variable: return kinds.Variable;
+ case CompletionItemKind.Class: return kinds.Class;
+ case CompletionItemKind.Interface: return kinds.Interface;
+ case CompletionItemKind.Module: return kinds.Module;
+ case CompletionItemKind.Property: return kinds.Property;
+ case CompletionItemKind.Unit: return kinds.Unit;
+ case CompletionItemKind.Value: return kinds.Value;
+ case CompletionItemKind.Enum: return kinds.Enum;
+ case CompletionItemKind.Keyword: return kinds.Keyword;
+ case CompletionItemKind.Snippet: return kinds.Snippet;
+ case CompletionItemKind.Color: return kinds.Color;
+ case CompletionItemKind.File: return kinds.File;
+ case CompletionItemKind.Reference: return kinds.Reference;
+ case CompletionItemKind.Folder: return kinds.Folder;
+ case CompletionItemKind.EnumMember: return kinds.EnumMember;
+ case CompletionItemKind.Constant: return kinds.Constant;
+ case CompletionItemKind.Struct: return kinds.Struct;
+ case CompletionItemKind.Event: return kinds.Event;
+ case CompletionItemKind.Operator: return kinds.Operator;
+ case CompletionItemKind.TypeParameter: return kinds.TypeParameter;
+ case CompletionItemKind.Text:
+ default:
+ return kinds.Text;
+ }
+}
+
+function documentationForItem(item: CompletionItem): string | monaco.IMarkdownString | undefined {
+ if (!item.documentation) {
+ return undefined;
+ }
+ if (MarkupContent.is(item.documentation)) {
+ if (item.documentation.kind === MarkupKind.Markdown) {
+ return { value: item.documentation.value };
+ } else {
+ return item.documentation.value;
+ }
+ }
+ return item.documentation;
+}
+
+function simpleStringDiff(str1: string, str2: string) {
+ let diff = 0;
+ for (let i = 0; i < Math.min(str1.length, str2.length); i++) {
+ if (str1[i] !== str2[i]) diff++;
+ }
+ return diff;
+}
+
+function isLetter(c: string) {
+ return c.toLowerCase() != c.toUpperCase();
+}
+
diff --git a/packages/editor-monaco/src/behaviors/diagnostics.ts b/packages/editor-monaco/src/behaviors/diagnostics.ts
new file mode 100644
index 000000000..ab19b5bc8
--- /dev/null
+++ b/packages/editor-monaco/src/behaviors/diagnostics.ts
@@ -0,0 +1,125 @@
+/*
+ * diagnostics.ts
+ *
+ * Copyright (C) 2026 by Posit Software, PBC
+ *
+ * Diagnostics for code cells (currently yaml validation), shown as monaco
+ * markers (squiggles with the message available on hover).
+ */
+
+import { Position } from "vscode-languageserver-types";
+
+import {
+ CodeViewCellContext,
+ codeViewCellContext,
+ kEndColumn,
+ kEndRow,
+ kStartColumn,
+ kStartRow,
+ LintItem,
+ stripYamlFrontmatterDelimiters
+} from "editor";
+import { lines } from "core";
+
+import { monaco } from "../monaco";
+
+import { Behavior, BehaviorContext, CodeEditor } from ".";
+
+const kMarkerOwner = "quarto";
+
+const EMPTY_CODEVIEW_SELECTION = { start: Position.create(0, 0), end: Position.create(0, 0) };
+
+export function diagnosticsBehavior(behaviorContext: BehaviorContext): Behavior {
+ // don't provide behavior if we don't have validation
+ if (!behaviorContext.pmContext.ui.codeview) {
+ return {};
+ }
+
+ // serialize requests so a slow earlier response can't overwrite a later one
+ let requestId = 0;
+
+ const updateDiagnostics = async (cellContext: CodeViewCellContext, editor: CodeEditor) => {
+ const id = ++requestId;
+ const diagnostics = await getDiagnostics(cellContext, behaviorContext);
+ const model = editor.getModel();
+ if (id !== requestId || !model || model.isDisposed()) {
+ return;
+ }
+ setMarkers(model, diagnostics || []);
+ };
+
+ return {
+ async init(pmNode, editor) {
+ const language = behaviorContext.options.lang(pmNode, pmNode.textContent);
+ if (language !== "yaml-frontmatter") return;
+
+ const filepath = behaviorContext.pmContext.ui.context.getDocumentPath();
+ if (filepath === null) return;
+
+ const code = lines(pmNode.textContent);
+ const strippedCodeLines = stripYamlFrontmatterDelimiters(code);
+
+ // here we hand-craft a cellContext because `codeViewCellContext(..)`
+ // returns undefined inside of init (the selection isn't in the cell yet)
+ const cellContext = {
+ filepath,
+ language: 'yaml',
+ code: strippedCodeLines,
+ cellBegin: 0,
+ cellEnd: code.length - 1,
+ selection: EMPTY_CODEVIEW_SELECTION
+ };
+
+ await updateDiagnostics(cellContext, editor);
+ },
+
+ async pmUpdate(_, _updatePmNode, editor) {
+ const filepath = behaviorContext.pmContext.ui.context.getDocumentPath();
+ if (filepath === null) return;
+
+ const cellContext = codeViewCellContext(filepath, behaviorContext.view.state);
+ if (cellContext === undefined) return;
+
+ await updateDiagnostics(cellContext, editor);
+ },
+
+ cleanup() {
+ requestId++;
+ }
+ };
+}
+
+async function getDiagnostics(
+ cellContext: CodeViewCellContext,
+ behaviorContext: BehaviorContext
+): Promise {
+ return await behaviorContext.pmContext.ui.codeview?.codeViewDiagnostics(cellContext);
+}
+
+function setMarkers(model: monaco.editor.ITextModel, diagnostics: LintItem[]) {
+ const markers = diagnostics.map((item): monaco.editor.IMarkerData => ({
+ severity: severityForType(item.type),
+ message: item.text,
+ // lint items are 0-based, monaco positions are 1-based
+ startLineNumber: item[kStartRow] + 1,
+ startColumn: item[kStartColumn] + 1,
+ endLineNumber: item[kEndRow] + 1,
+ endColumn: item[kEndColumn] + 1
+ }));
+ monaco.editor.setModelMarkers(model, kMarkerOwner, markers);
+}
+
+function severityForType(type: string): monaco.MarkerSeverity {
+ switch (type) {
+ case "warning":
+ return monaco.MarkerSeverity.Warning;
+ case "info":
+ case "information":
+ return monaco.MarkerSeverity.Info;
+ case "hint":
+ return monaco.MarkerSeverity.Hint;
+ case "error":
+ default:
+ return monaco.MarkerSeverity.Error;
+ }
+}
diff --git a/packages/editor-monaco/src/behaviors/diagram.ts b/packages/editor-monaco/src/behaviors/diagram.ts
new file mode 100644
index 000000000..c3d54dcad
--- /dev/null
+++ b/packages/editor-monaco/src/behaviors/diagram.ts
@@ -0,0 +1,50 @@
+/*
+ * diagram.ts
+ *
+ * Copyright (C) 2026 by Posit Software, PBC
+ */
+
+import debounce from "lodash.debounce";
+
+import { codeViewActiveBlockContext } from "editor";
+import { languageDiagramEngine } from "editor-core";
+
+import { Behavior, BehaviorContext } from ".";
+
+export function diagramBehavior(context: BehaviorContext): Behavior {
+
+ const onDiagramCodeChanged = debounce(() => {
+ previewDiagram(context, false);
+ }, 500);
+
+ return {
+ contentChanged(_ev, _editor, pmNode) {
+ const nodeLang = context.options.lang(pmNode, pmNode.textContent) || "";
+ const engine = languageDiagramEngine(nodeLang);
+ if (engine) {
+ onDiagramCodeChanged();
+ }
+ },
+ cleanup() {
+ onDiagramCodeChanged.cancel();
+ }
+ };
+}
+
+export function previewDiagram(context: BehaviorContext, activate: boolean) {
+ const ctx = codeViewActiveBlockContext(context.view.state);
+ if (ctx) {
+ const engine = languageDiagramEngine(ctx.activeLanguage);
+ if (engine) {
+ const activeBlock = ctx.blocks.find((block) => block.active);
+ if (activeBlock) {
+ context.pmContext.ui.codeview?.codeViewPreviewDiagram({
+ engine,
+ src: activeBlock.code,
+ },
+ activate
+ );
+ }
+ }
+ }
+}
diff --git a/packages/editor-monaco/src/behaviors/find.ts b/packages/editor-monaco/src/behaviors/find.ts
new file mode 100644
index 000000000..702ea0258
--- /dev/null
+++ b/packages/editor-monaco/src/behaviors/find.ts
@@ -0,0 +1,72 @@
+/*
+ * find.ts
+ *
+ * Copyright (C) 2026 by Posit Software, PBC
+ */
+
+import { scrollCodeViewElementIntoView } from "editor";
+
+import { monaco } from "../monaco";
+
+import { Behavior, BehaviorContext, CodeEditor } from ".";
+
+export function findBehavior(context: BehaviorContext): Behavior {
+
+ const { view, getPos } = context;
+
+ let findDecorations: monaco.editor.IEditorDecorationsCollection | undefined;
+
+ return {
+
+ init(_pmNode, editor) {
+ findDecorations = editor.createDecorationsCollection();
+ },
+
+ pmUpdate(_prevNode, updateNode, editor) {
+
+ const model = editor.getModel();
+ if (!model || !findDecorations) {
+ return;
+ }
+
+ // get the find decorations
+ const findMarkers: monaco.editor.IModelDeltaDecoration[] = [];
+ const decorations = context.pmContext.find.decorations();
+ const pos = getPos();
+ if (decorations && pos !== undefined) {
+ const decos = decorations.find(pos, pos + updateNode.nodeSize - 1);
+ decos.forEach((deco) => {
+ if (deco.from !== view.state.selection.from && deco.to !== view.state.selection.to) {
+ findMarkers.push({
+ range: monaco.Range.fromPositions(
+ model.getPositionAt(deco.from - pos - 1),
+ model.getPositionAt(deco.to - pos - 1)
+ ),
+ options: { inlineClassName: "pm-find-text" }
+ });
+ } else {
+ // ensure that the selection is visible
+ scrollLineIntoView(editor, model.getPositionAt(deco.from - pos - 1), context);
+ }
+ });
+ }
+
+ findDecorations.set(findMarkers);
+ },
+
+ cleanup() {
+ findDecorations?.clear();
+ }
+ };
+}
+
+// scroll the outer (prosemirror) scroll container so that the given position
+// within the editor is visible. scrollCodeViewElementIntoView wants an element
+// positioned relative to the code view dom, so we hand it the line's box.
+function scrollLineIntoView(editor: CodeEditor, position: monaco.Position, context: BehaviorContext) {
+ const visible = editor.getScrolledVisiblePosition(position);
+ if (visible) {
+ const lineBox = { offsetTop: visible.top, offsetHeight: visible.height } as HTMLElement;
+ scrollCodeViewElementIntoView(lineBox, context.dom, context.view);
+ }
+}
diff --git a/packages/editor-monaco/src/behaviors/firstlinemeta.ts b/packages/editor-monaco/src/behaviors/firstlinemeta.ts
new file mode 100644
index 000000000..5bf211e2a
--- /dev/null
+++ b/packages/editor-monaco/src/behaviors/firstlinemeta.ts
@@ -0,0 +1,46 @@
+/*
+ * firstlinemeta.ts
+ *
+ * Copyright (C) 2026 by Posit Software, PBC
+ *
+ * For code views whose first line is metadata (e.g. the `{r}` line of a code
+ * chunk), render that line in a lighter color.
+ */
+
+import { Behavior, BehaviorContext } from ".";
+
+import { monaco } from "../monaco";
+
+export function firstLineMetaBehavior(context: BehaviorContext): Behavior {
+
+ if (!context.options.firstLineMeta) {
+ return {};
+ }
+
+ let decorations: monaco.editor.IEditorDecorationsCollection | undefined;
+
+ return {
+ init(_pmNode, editor) {
+ decorations = editor.createDecorationsCollection([{
+ range: new monaco.Range(1, 1, 1, 1),
+ options: {
+ isWholeLine: true,
+ inlineClassName: "pm-monaco-first-line-meta",
+ // keep the decoration on the first line as it is edited
+ stickiness: monaco.editor.TrackedRangeStickiness.GrowsOnlyWhenTypingBefore
+ }
+ }]);
+ // the decoration can get pushed off line 1 when text is inserted at the
+ // very start, so pin it back after every change
+ editor.onDidChangeModelContent(() => {
+ decorations?.set([{
+ range: new monaco.Range(1, 1, 1, 1),
+ options: { isWholeLine: true, inlineClassName: "pm-monaco-first-line-meta" }
+ }]);
+ });
+ },
+ cleanup() {
+ decorations?.clear();
+ }
+ };
+}
diff --git a/packages/editor-monaco/src/behaviors/index.ts b/packages/editor-monaco/src/behaviors/index.ts
new file mode 100644
index 000000000..ace04c0b8
--- /dev/null
+++ b/packages/editor-monaco/src/behaviors/index.ts
@@ -0,0 +1,95 @@
+/*
+ * index.ts
+ *
+ * Copyright (C) 2026 by Posit Software, PBC
+ */
+
+import { Node as ProsemirrorNode } from 'prosemirror-model';
+import { EditorView as PMEditorView } from "prosemirror-view";
+
+import { CodeViewOptions, ExtensionContext } from "editor";
+
+import { monaco } from "../monaco";
+
+import { langModeBehavior } from './langmode';
+import { keyboardBehavior } from './keyboard';
+import { findBehavior } from './find';
+import { trackSelectionBehavior } from './trackselection';
+import { themeBehavior } from './theme';
+import { prefsBehavior } from './prefs';
+import { yamlOptionBehavior } from './yamloption';
+import { toolbarBehavior } from './toolbar';
+import { diagramBehavior } from './diagram';
+import { completionBehavior } from './completion';
+import { diagnosticsBehavior } from './diagnostics';
+import { firstLineMetaBehavior } from './firstlinemeta';
+
+export type CodeEditor = monaco.editor.IStandaloneCodeEditor;
+export type ContentChangedEvent = monaco.editor.IModelContentChangedEvent;
+
+export interface Behavior {
+ // options merged into the editor construction options
+ options?: monaco.editor.IStandaloneEditorConstructionOptions;
+ // called once the editor has been created
+ init?: (pmNode: ProsemirrorNode, editor: CodeEditor) => void;
+ // called when the monaco model changes (both user and prosemirror initiated)
+ contentChanged?: (ev: ContentChangedEvent, editor: CodeEditor, pmNode: ProsemirrorNode) => void;
+ // called when the prosemirror node is updated
+ pmUpdate?: (prevNode: ProsemirrorNode, updateNode: ProsemirrorNode, editor: CodeEditor) => void;
+ cleanup?: VoidFunction;
+}
+
+export interface BehaviorContext {
+ dom: HTMLElement;
+ view: PMEditorView;
+ getPos: () => number | undefined;
+ options: CodeViewOptions;
+ pmContext: ExtensionContext;
+ withState: WithState;
+}
+
+export enum State { Updating, Escaping }
+export type WithState = (state: State, fn: () => void) => void;
+
+export function createBehaviors(context: BehaviorContext): Behavior[] {
+ return [
+ langModeBehavior(context),
+ completionBehavior(context),
+ findBehavior(context),
+ themeBehavior(context),
+ prefsBehavior(context),
+ trackSelectionBehavior(context),
+ yamlOptionBehavior(context),
+ toolbarBehavior(context),
+ diagramBehavior(context),
+ diagnosticsBehavior(context),
+ firstLineMetaBehavior(context),
+ keyboardBehavior(context)
+ ];
+}
+
+export function behaviorOptions(behaviors: Behavior[]): monaco.editor.IStandaloneEditorConstructionOptions {
+ return behaviors.reduce(
+ (options, behavior) => ({ ...options, ...(behavior.options || {}) }), {}
+ );
+}
+
+export function behaviorInit(behaviors: Behavior[], pmNode: ProsemirrorNode, editor: CodeEditor) {
+ behaviors.forEach(behavior => behavior.init?.(pmNode, editor));
+}
+
+export function behaviorContentChanged(
+ behaviors: Behavior[], ev: ContentChangedEvent, editor: CodeEditor, pmNode: ProsemirrorNode
+) {
+ behaviors.forEach(behavior => behavior.contentChanged?.(ev, editor, pmNode));
+}
+
+export function behaviorPmUpdate(
+ behaviors: Behavior[], prevNode: ProsemirrorNode, updateNode: ProsemirrorNode, editor: CodeEditor
+) {
+ behaviors.forEach(behavior => behavior.pmUpdate?.(prevNode, updateNode, editor));
+}
+
+export function behaviorCleanup(behaviors: Behavior[]) {
+ behaviors.forEach(behavior => behavior.cleanup?.());
+}
diff --git a/packages/editor-monaco/src/behaviors/keyboard.ts b/packages/editor-monaco/src/behaviors/keyboard.ts
new file mode 100644
index 000000000..c069550ed
--- /dev/null
+++ b/packages/editor-monaco/src/behaviors/keyboard.ts
@@ -0,0 +1,163 @@
+/*
+ * keyboard.ts
+ *
+ * Copyright (C) 2026 by Posit Software, PBC
+ */
+
+import { EditorView as PMEditorView } from "prosemirror-view";
+import { undo, redo } from "prosemirror-history";
+import { exitCode, setBlockType } from "prosemirror-commands";
+
+import { codeViewActiveBlockContext, handleArrowToAdjacentNode } from "editor";
+
+import { monaco } from "../monaco";
+
+import { Behavior, BehaviorContext, CodeEditor, State } from ".";
+
+const { KeyMod, KeyCode } = monaco;
+
+// context keys used as preconditions for our keybindings (so that when they
+// don't apply monaco's default handling of the key is used)
+const kCanEscapeUp = "quartoCodeViewCanEscapeUp";
+const kCanEscapeDown = "quartoCodeViewCanEscapeDown";
+const kCanEscapeLeft = "quartoCodeViewCanEscapeLeft";
+const kCanEscapeRight = "quartoCodeViewCanEscapeRight";
+const kAtStart = "quartoCodeViewAtStart";
+
+// don't let monaco consume keys that the host binds (render, run line)
+monaco.editor.addKeybindingRules([
+ { keybinding: KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.KeyK, command: null },
+ { keybinding: KeyMod.Alt | KeyCode.Enter, command: null },
+ { keybinding: KeyMod.CtrlCmd | KeyCode.KeyF, command: null },
+]);
+
+export function keyboardBehavior(context: BehaviorContext): Behavior {
+
+ const { view, getPos } = context;
+
+ const escape = (dir: 1 | -1, editor: CodeEditor) => {
+ context.withState(State.Escaping, () => {
+ const pos = getPos();
+ if (pos !== undefined) {
+ view.focus();
+ handleArrowToAdjacentNode(pos, dir, view.state, view.dispatch);
+ view.focus();
+ }
+ });
+ // keep monaco's selection out of the way (prosemirror now owns focus)
+ editor.setSelection(new monaco.Selection(1, 1, 1, 1));
+ };
+
+ return {
+ init(_pmNode, editor) {
+
+ const canEscapeUp = editor.createContextKey(kCanEscapeUp, false);
+ const canEscapeDown = editor.createContextKey(kCanEscapeDown, false);
+ const canEscapeLeft = editor.createContextKey(kCanEscapeLeft, false);
+ const canEscapeRight = editor.createContextKey(kCanEscapeRight, false);
+ const atStart = editor.createContextKey(kAtStart, false);
+
+ const updateContextKeys = () => {
+ const model = editor.getModel();
+ const sel = editor.getSelection();
+ if (!model || !sel) {
+ return;
+ }
+ const empty = sel.isEmpty();
+ const pos = sel.getPosition();
+ const lastLine = model.getLineCount();
+ canEscapeUp.set(empty && pos.lineNumber === 1);
+ canEscapeDown.set(empty && pos.lineNumber === lastLine);
+ canEscapeLeft.set(empty && pos.lineNumber === 1 && pos.column === 1);
+ canEscapeRight.set(empty && pos.lineNumber === lastLine && pos.column === model.getLineMaxColumn(lastLine));
+ atStart.set(empty && pos.lineNumber === 1 && pos.column === 1);
+ };
+ updateContextKeys();
+ editor.onDidChangeCursorSelection(updateContextKeys);
+ editor.onDidChangeModelContent(updateContextKeys);
+
+ const notPopup = "!suggestWidgetVisible && !parameterHintsVisible";
+
+ editor.addAction({
+ id: "quarto.codeview.escapeUp",
+ label: "Move to Previous Block",
+ keybindings: [KeyCode.UpArrow],
+ precondition: `${kCanEscapeUp} && ${notPopup}`,
+ run: (ed) => escape(-1, ed as CodeEditor)
+ });
+ editor.addAction({
+ id: "quarto.codeview.escapeLeft",
+ label: "Move to Previous Block",
+ keybindings: [KeyCode.LeftArrow],
+ precondition: `${kCanEscapeLeft} && ${notPopup}`,
+ run: (ed) => escape(-1, ed as CodeEditor)
+ });
+ editor.addAction({
+ id: "quarto.codeview.escapeDown",
+ label: "Move to Next Block",
+ keybindings: [KeyCode.DownArrow],
+ precondition: `${kCanEscapeDown} && ${notPopup}`,
+ run: (ed) => escape(1, ed as CodeEditor)
+ });
+ editor.addAction({
+ id: "quarto.codeview.escapeRight",
+ label: "Move to Next Block",
+ keybindings: [KeyCode.RightArrow],
+ precondition: `${kCanEscapeRight} && ${notPopup}`,
+ run: (ed) => escape(1, ed as CodeEditor)
+ });
+
+ // undo/redo are routed to prosemirror (which owns the document history)
+ editor.addAction({
+ id: "quarto.codeview.undo",
+ label: "Undo",
+ keybindings: [KeyMod.CtrlCmd | KeyCode.KeyZ],
+ run: () => { undo(view.state, view.dispatch); }
+ });
+ editor.addAction({
+ id: "quarto.codeview.redo",
+ label: "Redo",
+ keybindings: [KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.KeyZ, KeyMod.CtrlCmd | KeyCode.KeyY],
+ run: () => { redo(view.state, view.dispatch); }
+ });
+
+ // backspace at the very start of the block converts it to a paragraph
+ editor.addAction({
+ id: "quarto.codeview.backspaceAtStart",
+ label: "Convert to Paragraph",
+ keybindings: [KeyCode.Backspace, KeyMod.CtrlCmd | KeyCode.Backspace],
+ precondition: `${kAtStart} && ${notPopup}`,
+ run: () => backspaceAtStart(view)
+ });
+
+ // shift-enter executes the cell (or exits the code block if not executable)
+ editor.addAction({
+ id: "quarto.codeview.shiftEnter",
+ label: "Run Cell",
+ keybindings: [KeyMod.Shift | KeyCode.Enter],
+ precondition: notPopup,
+ run: (ed) => {
+ const cvContext = codeViewActiveBlockContext(view.state);
+ if (cvContext && context.pmContext.ui.context.executableLanguges?.().includes(cvContext.activeLanguage)) {
+ context.pmContext.ui.codeview?.codeViewExecute("cell+advance", cvContext);
+ } else {
+ const model = ed.getModel();
+ const sel = ed.getSelection();
+ if (model && sel && sel.isEmpty() &&
+ model.getOffsetAt(sel.getPosition()) === model.getValueLength()) {
+ exitCode(view.state, view.dispatch);
+ view.focus();
+ } else {
+ ed.trigger("keyboard", "type", { text: "\n" });
+ }
+ }
+ }
+ });
+ }
+ };
+}
+
+const backspaceAtStart = (pmView: PMEditorView) => {
+ setBlockType(pmView.state.schema.nodes.paragraph)(pmView.state, pmView.dispatch);
+ setTimeout(() => pmView.focus(), 20);
+};
diff --git a/packages/editor-monaco/src/behaviors/langmode.ts b/packages/editor-monaco/src/behaviors/langmode.ts
new file mode 100644
index 000000000..62f30258a
--- /dev/null
+++ b/packages/editor-monaco/src/behaviors/langmode.ts
@@ -0,0 +1,45 @@
+/*
+ * langmode.ts
+ *
+ * Copyright (C) 2026 by Posit Software, PBC
+ */
+
+import { Node as ProsemirrorNode } from 'prosemirror-model';
+
+import { monaco } from "../monaco";
+import { initTextMate } from "../textmate";
+import { monacoLanguageId } from '../languages';
+
+import { Behavior, BehaviorContext, CodeEditor } from '.';
+
+export function langModeBehavior(context: BehaviorContext): Behavior {
+
+ let disposed = false;
+
+ const nodeLang = (nd: ProsemirrorNode) => context.options.lang(nd, nd.textContent) || '';
+
+ const setMode = (lang: string, editor: CodeEditor) => {
+ // languages are registered once the host has provided them
+ initTextMate(context.pmContext.ui.codeview).then(() => {
+ const model = editor.getModel();
+ if (!disposed && model) {
+ monaco.editor.setModelLanguage(model, monacoLanguageId(lang));
+ }
+ });
+ };
+
+ return {
+ init: (pmNode, editor) => {
+ setMode(nodeLang(pmNode), editor);
+ },
+ pmUpdate: (prevNode, updateNode, editor) => {
+ const updateLang = nodeLang(updateNode);
+ if (nodeLang(prevNode) !== updateLang) {
+ setMode(updateLang, editor);
+ }
+ },
+ cleanup: () => {
+ disposed = true;
+ }
+ };
+}
diff --git a/packages/editor-monaco/src/behaviors/prefs.ts b/packages/editor-monaco/src/behaviors/prefs.ts
new file mode 100644
index 000000000..345ef6c98
--- /dev/null
+++ b/packages/editor-monaco/src/behaviors/prefs.ts
@@ -0,0 +1,61 @@
+/*
+ * prefs.ts
+ *
+ * Copyright (C) 2026 by Posit Software, PBC
+ */
+
+import { PrefsChangedEvent } from "editor";
+
+import { monaco } from "../monaco";
+
+import { Behavior, BehaviorContext, CodeEditor } from ".";
+
+export function prefsBehavior(context: BehaviorContext): Behavior {
+
+ const configurePrefs = (editor: CodeEditor) => {
+
+ // alias prefs
+ const prefs = context.pmContext.ui.prefs;
+
+ // line numbers
+ const options = context.options;
+ const showLineNumbers = !!options.lineNumbers && prefs.lineNumbers();
+ const formatter = options.lineNumberFormatter;
+ const lineNumbers: monaco.editor.LineNumbersType = showLineNumbers
+ ? (formatter ? (lineNo: number) => formatter(lineNo) : "on")
+ : "off";
+
+ const autoClosing = prefs.autoClosingBrackets() ? "languageDefined" : "never";
+
+ editor.updateOptions({
+ lineNumbers,
+ lineNumbersMinChars: showLineNumbers ? 3 : 0,
+ lineDecorationsWidth: showLineNumbers ? 8 : 0,
+ selectionHighlight: prefs.highlightSelectedWord(),
+ renderWhitespace: prefs.showWhitespace() ? "all" : "none",
+ autoClosingBrackets: autoClosing,
+ autoClosingQuotes: autoClosing,
+ cursorBlinking: prefs.blinkingCursor() ? "blink" : "solid",
+ quickSuggestions: prefs.quickSuggestions()
+ });
+
+ editor.getModel()?.updateOptions({
+ tabSize: prefs.tabWidth(),
+ insertSpaces: prefs.spacesForTab()
+ });
+ };
+
+ let unsubscribe: VoidFunction;
+
+ return {
+ init(_pmNode, editor) {
+ configurePrefs(editor);
+ unsubscribe = context.pmContext.events.subscribe(PrefsChangedEvent, () => {
+ configurePrefs(editor);
+ });
+ },
+ cleanup: () => {
+ unsubscribe?.();
+ }
+ };
+}
diff --git a/packages/editor-monaco/src/behaviors/theme.ts b/packages/editor-monaco/src/behaviors/theme.ts
new file mode 100644
index 000000000..f95229cc7
--- /dev/null
+++ b/packages/editor-monaco/src/behaviors/theme.ts
@@ -0,0 +1,148 @@
+/*
+ * theme.ts
+ *
+ * Copyright (C) 2026 by Posit Software, PBC
+ *
+ * Apply the active VS Code color theme (provided by the host) to monaco, and
+ * apply per-editor font and background settings.
+ */
+
+import { PrefsChangedEvent, ThemeChangedEvent } from "editor";
+import { CodeViewEditorFont } from "editor-types";
+
+import { Behavior, BehaviorContext, CodeEditor } from ".";
+import { activeThemeColors, applyColorTheme, initTextMate } from "../textmate";
+
+// the color theme is shared by all editors, so the theme is fetched/applied
+// once per change and then every editor is notified to reconfigure itself
+let themeRequest: Promise | undefined;
+let themeRequestedAt = 0;
+const themeListeners = new Set();
+
+// the source editor's font settings (shared by all editors)
+let editorFont: CodeViewEditorFont | undefined;
+let fontRequest: Promise | undefined;
+
+function requestEditorFont(context: BehaviorContext, force: boolean): Promise {
+ if (!fontRequest || force) {
+ const codeview = context.pmContext.ui.codeview;
+ fontRequest = (codeview ? codeview.codeViewEditorFont() : Promise.resolve(undefined))
+ .then(font => { editorFont = font; })
+ .catch(error => console.error("Error reading editor font", error))
+ .then(() => themeListeners.forEach(listener => listener()));
+ }
+ return fontRequest;
+}
+
+// vscode stamps the webview body with the id of the theme it is currently
+// displayed with (updated live, including during theme preview)
+function webviewThemeId(): string | undefined {
+ return document.body.dataset.vscodeThemeId || document.body.dataset.vscodeThemeName || undefined;
+}
+
+function webviewDarkMode(fallback: boolean): boolean {
+ const kind = document.body.dataset.vscodeThemeKind;
+ if (kind) {
+ return kind === "vscode-dark" || kind === "vscode-high-contrast";
+ }
+ return fallback;
+}
+
+function requestColorTheme(context: BehaviorContext, force: boolean): Promise {
+ // every editor observes the same theme change event, so coalesce their
+ // forced requests into one
+ const now = Date.now();
+ if (!themeRequest || (force && (now - themeRequestedAt) > 100)) {
+ themeRequestedAt = now;
+ const codeview = context.pmContext.ui.codeview;
+ const darkMode = webviewDarkMode(context.pmContext.theme().darkMode);
+ themeRequest = initTextMate(codeview)
+ .then(() => codeview?.codeViewColorTheme(webviewThemeId()))
+ .then(theme => applyColorTheme(theme, darkMode))
+ .catch(error => {
+ console.error("Error applying color theme", error);
+ applyColorTheme(undefined, darkMode);
+ })
+ .then(() => themeListeners.forEach(listener => listener()));
+ }
+ return themeRequest;
+}
+
+// re-apply the theme whenever vscode changes the theme attributes on the body
+let bodyObserver: MutationObserver | undefined;
+function observeWebviewTheme(context: BehaviorContext) {
+ if (!bodyObserver) {
+ bodyObserver = new MutationObserver(() => {
+ requestColorTheme(context, true);
+ });
+ bodyObserver.observe(document.body, {
+ attributes: true,
+ attributeFilter: ["data-vscode-theme-id", "data-vscode-theme-name", "data-vscode-theme-kind"]
+ });
+ }
+}
+
+export function themeBehavior(context: BehaviorContext): Behavior {
+
+ let unsubscribe: VoidFunction;
+
+ const chunkBackground = !!context.options.classes?.includes("pm-chunk-background-color");
+
+ const configureEditor = (editor: CodeEditor) => {
+
+ // fonts (the same settings the source editor uses)
+ if (editorFont) {
+ editor.updateOptions({
+ fontFamily: editorFont.fontFamily,
+ fontSize: editorFont.fontSize,
+ fontWeight: editorFont.fontWeight,
+ fontLigatures: editorFont.fontLigatures,
+ lineHeight: editorFont.lineHeight,
+ letterSpacing: editorFont.letterSpacing
+ });
+ } else {
+ const editorTheme = context.pmContext.theme();
+ editor.updateOptions({
+ fontFamily: editorTheme.fixedWidthFont,
+ fontSize: Math.round(editorTheme.fixedWidthFontSizePt * 96 / 72)
+ });
+ }
+
+ // background (monaco's theme is global, so per-editor backgrounds are
+ // applied via a css variable on our container)
+ const colors = activeThemeColors();
+ const background = chunkBackground
+ ? (colors["notebook.cellEditorBackground"] || colors["editor.background"])
+ : colors["editor.background"];
+ if (background) {
+ context.dom.style.setProperty("--pm-code-background", background);
+ context.dom.classList.add("pm-monaco-editor-background");
+ } else {
+ context.dom.style.removeProperty("--pm-code-background");
+ context.dom.classList.remove("pm-monaco-editor-background");
+ }
+ };
+
+ return {
+ init: (_pmNode, editor) => {
+ const listener = () => configureEditor(editor);
+ themeListeners.add(listener);
+ configureEditor(editor);
+ observeWebviewTheme(context);
+ requestColorTheme(context, false).then(listener);
+ requestEditorFont(context, false).then(listener);
+ const unsubscribeTheme = context.pmContext.events.subscribe(ThemeChangedEvent, () => {
+ requestColorTheme(context, true);
+ });
+ const unsubscribePrefs = context.pmContext.events.subscribe(PrefsChangedEvent, () => {
+ requestEditorFont(context, true);
+ });
+ unsubscribe = () => {
+ unsubscribeTheme();
+ unsubscribePrefs();
+ themeListeners.delete(listener);
+ };
+ },
+ cleanup: () => unsubscribe?.()
+ };
+}
diff --git a/packages/editor-monaco/src/behaviors/toolbar.ts b/packages/editor-monaco/src/behaviors/toolbar.ts
new file mode 100644
index 000000000..a9cdbbfe4
--- /dev/null
+++ b/packages/editor-monaco/src/behaviors/toolbar.ts
@@ -0,0 +1,138 @@
+/*
+ * toolbar.ts
+ *
+ * Copyright (C) 2026 by Posit Software, PBC
+ */
+
+import { Transaction } from "prosemirror-state";
+
+import { languageDiagramEngine } from "editor-core";
+
+import { CodeViewActiveBlockContext, codeViewActiveBlockContext, CodeViewExecute, DispatchEvent } from "editor";
+
+import { Behavior, BehaviorContext } from ".";
+import { previewDiagram } from "./diagram";
+import { asEditorSelection } from "./trackselection";
+
+export function toolbarBehavior(context: BehaviorContext): Behavior {
+
+ let unsubscribe: VoidFunction;
+ let toolbar: HTMLElement | undefined;
+
+ const showToolbar = (show: boolean) => {
+ if (show && !toolbar) {
+ toolbar = createToolbarPanel();
+ context.dom.appendChild(toolbar);
+ } else if (!show && toolbar) {
+ toolbar.remove();
+ toolbar = undefined;
+ }
+ };
+
+ function createToolbarPanel() {
+
+ // get context
+ const cvContext = codeViewActiveBlockContext(context.view.state)!;
+
+ // create toolbar
+ const panel = document.createElement("div");
+ panel.classList.add("pm-monaco-toolbar");
+
+ // add a button
+ const addButton = (title: string, onClick: VoidFunction, ...classes: string[]) => {
+ const button = document.createElement("i");
+ button.title = title;
+ button.classList.add("codicon", ...classes);
+ button.addEventListener('mousedown', (ev) => {
+ // don't steal focus from the editor
+ ev.preventDefault();
+ });
+ button.addEventListener('click', (ev) => {
+ onClick();
+ ev.preventDefault();
+ ev.stopPropagation();
+ return false;
+ });
+ panel.appendChild(button);
+ return button;
+ };
+
+ const addExecuteButton = (execute: CodeViewExecute, title: string, ...classes: string[]) => {
+ addButton(title, () => {
+ context.pmContext.ui.codeview?.codeViewExecute(execute, cvContext);
+ }, ...classes);
+ };
+
+ // buttons (conditional on context)
+ const t = context.pmContext.ui.context.translateText;
+ const diagramEngine = languageDiagramEngine(cvContext.activeLanguage);
+ if (diagramEngine) {
+ addButton(t("Preview Diagram"), () => {
+ previewDiagram(context, true);
+ }, "codicon-zoom-in", "pm-codeview-run-button");
+ } else {
+ if (runnableCellsAbove(cvContext)) {
+ addExecuteButton("above", t("Run Cells Above"), "codicon-run-above", "pm-codeview-run-other-button");
+ }
+ if (runnableCellsBelow(cvContext)) {
+ addExecuteButton("below", t("Run Cells Below"), "codicon-run-below", "pm-codeview-run-other-button");
+ }
+ addExecuteButton("cell", t("Run Cell"), "codicon-play", "pm-codeview-run-button");
+ }
+
+ return panel;
+ }
+
+ return {
+ init(pmNode, editor) {
+ unsubscribe = context.pmContext.events.subscribe(DispatchEvent, (tr: Transaction | undefined) => {
+ // track selection-only changes
+ if (tr && tr.selectionSet && !tr.docChanged) {
+ const selection = asEditorSelection(context.view, editor, context.getPos);
+ if (selection) {
+ const nodeLang = context.options.lang(pmNode, pmNode.textContent);
+ const cvContext = codeViewActiveBlockContext(context.view.state);
+ const show = !!cvContext && !!nodeLang &&
+ // is an executable language
+ (!!context.pmContext.ui.context.executableLanguges?.().includes(nodeLang) ||
+ // or is a diagram language
+ !!languageDiagramEngine(nodeLang));
+ // rebuild so the above/below buttons reflect the current document
+ showToolbar(false);
+ showToolbar(show);
+ } else {
+ showToolbar(false);
+ }
+ }
+ });
+ },
+ cleanup: () => {
+ unsubscribe?.();
+ showToolbar(false);
+ },
+ };
+}
+
+function runnableCellsAbove(context: CodeViewActiveBlockContext) {
+ const activeIndex = context.blocks.findIndex(block => block.active);
+ if (activeIndex !== -1) {
+ for (let i = 0; i < activeIndex; i++) {
+ if (context.blocks[i].language === context.activeLanguage) {
+ return true;
+ }
+ }
+ }
+ return false;
+}
+
+function runnableCellsBelow(context: CodeViewActiveBlockContext) {
+ const activeIndex = context.blocks.findIndex(block => block.active);
+ if (activeIndex !== -1) {
+ for (let i = (activeIndex + 1); i < context.blocks.length; i++) {
+ if (context.blocks[i]?.language === context.activeLanguage) {
+ return true;
+ }
+ }
+ }
+ return false;
+}
diff --git a/packages/editor-monaco/src/behaviors/trackselection.ts b/packages/editor-monaco/src/behaviors/trackselection.ts
new file mode 100644
index 000000000..179db93c0
--- /dev/null
+++ b/packages/editor-monaco/src/behaviors/trackselection.ts
@@ -0,0 +1,118 @@
+/*
+ * trackselection.ts
+ *
+ * Copyright (C) 2026 by Posit Software, PBC
+ */
+
+import debounce from 'lodash.debounce';
+
+import { EditorView as PMEditorView } from "prosemirror-view";
+import { TextSelection, Transaction } from "prosemirror-state";
+
+import { DispatchEvent, codeViewCellContext, kCodeViewNextLineTransaction } from "editor";
+
+import { monaco } from "../monaco";
+
+import { Behavior, BehaviorContext, CodeEditor, State } from ".";
+
+// track the selection in prosemirror
+export function trackSelectionBehavior(context: BehaviorContext): Behavior {
+
+ let unsubscribe: VoidFunction;
+
+ // 500ms debounced function for code view assist request
+ const codeViewAssist = debounce(() => {
+ // get path and context (bail if we can't)
+ const filepath = context.pmContext.ui.context.getDocumentPath();
+ if (!filepath) {
+ return;
+ }
+ const cvContext = codeViewCellContext(filepath, context.view.state);
+ if (cvContext) {
+ context.pmContext.ui.codeview?.codeViewAssist(cvContext);
+ }
+ }, 500);
+
+ return {
+
+ init(_pmNode, editor) {
+ unsubscribe = context.pmContext.events.subscribe(DispatchEvent, (tr: Transaction | undefined) => {
+ if (tr) {
+ // track selection changes that occur when we don't have focus
+ if (tr.selectionSet && !tr.docChanged && (tr.selection instanceof TextSelection)) {
+ const selection = asEditorSelection(context.view, editor, context.getPos)
+ ?? new monaco.Selection(1, 1, 1, 1);
+ // don't echo back a selection that originated here (or that we
+ // already have) or we'd lose the user's selection direction
+ const current = editor.getSelection();
+ if (!current || !current.equalsSelection(selection)) {
+ context.withState(State.Updating, () => {
+ editor.setSelection(selection);
+ });
+ }
+ } else if (tr.getMeta(kCodeViewNextLineTransaction) === true) {
+ // NOTE: this is a special directive to advance to the next line. as distinct
+ // from the block above it is not a reporting of a change in the PM selection
+ // but rather an instruction to move the selection to the next line. as
+ // such we do not enclose the code in State.Updating, because we want an update
+ // to the PM selection to occur
+ const selection = asEditorSelection(context.view, editor, context.getPos);
+ if (selection) {
+ const before = editor.getPosition()?.lineNumber;
+ editor.trigger("quarto", "cursorDown", null);
+ if (editor.getPosition()?.lineNumber !== before) {
+ editor.trigger("quarto", "cursorLineStart", null);
+ }
+ }
+ // for other selection changes
+ } else if (editor.hasTextFocus() && tr.selectionSet && (tr.selection instanceof TextSelection)) {
+ codeViewAssist();
+ }
+ }
+ });
+ },
+
+ cleanup: () => {
+ codeViewAssist.cancel();
+ unsubscribe?.();
+ }
+ };
+
+}
+
+/**
+ * Map the prosemirror selection into a monaco selection for this code view
+ * (undefined if the prosemirror selection doesn't intersect the code view)
+ */
+export const asEditorSelection = (
+ pmView: PMEditorView,
+ editor: CodeEditor,
+ getPos: () => number | undefined
+): monaco.Selection | undefined => {
+ const model = editor.getModel();
+ const pos = getPos();
+ if (model && pos !== undefined) {
+ const offset = pos + 1;
+ const node = pmView.state.doc.nodeAt(pos);
+ if (node) {
+ const nodeSize = node.nodeSize;
+ const selection = pmView.state.selection;
+ const range = { from: offset, to: offset + nodeSize - 1 };
+ const isWithin = (p: number) => p >= range.from && p < range.to;
+ if (isWithin(selection.from) || isWithin(selection.to)) {
+ // preserve the selection direction (anchor/head rather than from/to)
+ const clamp = (p: number) => Math.max(0, Math.min(p - offset, model.getValueLength()));
+ return monaco.Selection.fromPositions(
+ model.getPositionAt(clamp(selection.anchor)),
+ model.getPositionAt(clamp(selection.head))
+ );
+ } else if (selection.from <= range.from && selection.to >= range.to) {
+ return monaco.Selection.fromPositions(
+ model.getPositionAt(0),
+ model.getPositionAt(model.getValueLength())
+ );
+ }
+ }
+ }
+ return undefined;
+};
diff --git a/packages/editor-monaco/src/behaviors/yamloption.ts b/packages/editor-monaco/src/behaviors/yamloption.ts
new file mode 100644
index 000000000..5f51acec7
--- /dev/null
+++ b/packages/editor-monaco/src/behaviors/yamloption.ts
@@ -0,0 +1,93 @@
+/*
+ * yamloption.ts
+ *
+ * Copyright (C) 2026 by Posit Software, PBC
+ *
+ * Continue `#| ` option comment lines when the user presses Enter on one
+ * (and clear the line when Enter is pressed on an empty `#|`).
+ */
+
+import { Node as ProsemirrorNode } from 'prosemirror-model';
+
+import { editorLanguage } from "editor-core";
+
+import { monaco } from "../monaco";
+
+import { Behavior, BehaviorContext, CodeEditor } from ".";
+
+const kOnOptionComment = "quartoCodeViewOnOptionComment";
+
+export function yamlOptionBehavior(context: BehaviorContext): Behavior {
+
+ // track current option comment prefix (e.g. "#| ") for the node's language
+ let optionComment: string | undefined;
+ const updateLanguage = (nd: ProsemirrorNode) => {
+ const language = context.options.lang(nd, nd.textContent) || '';
+ const langComment = editorLanguage(language)?.comment;
+ optionComment = langComment ? `${langComment}| ` : undefined;
+ };
+
+ return {
+
+ init(pmNode, editor) {
+ updateLanguage(pmNode);
+
+ // context key: is the cursor on an option comment line?
+ const onOptionComment = editor.createContextKey(kOnOptionComment, false);
+ const updateContextKey = () => {
+ const line = currentLine(editor);
+ onOptionComment.set(
+ !!optionComment && !!line &&
+ (line.trim() === optionComment.trim() || line.startsWith(optionComment))
+ );
+ };
+ editor.onDidChangeCursorPosition(updateContextKey);
+ editor.onDidChangeModelContent(updateContextKey);
+
+ editor.addAction({
+ id: "quarto.codeview.optionCommentEnter",
+ label: "Continue Option Comment",
+ keybindings: [monaco.KeyCode.Enter],
+ precondition: `${kOnOptionComment} && !suggestWidgetVisible && !inSnippetMode && !parameterHintsVisible`,
+ run: (ed) => {
+ const line = currentLine(ed as CodeEditor);
+ if (!optionComment || line === undefined) {
+ return;
+ }
+ const model = ed.getModel()!;
+ const lineNumber = ed.getPosition()!.lineNumber;
+ if (line.trim() === optionComment.trim()) {
+ // empty option comment: clear it and move to a fresh line
+ ed.executeEdits("quarto", [{
+ range: new monaco.Range(lineNumber, 1, lineNumber, model.getLineMaxColumn(lineNumber)),
+ text: "\n"
+ }]);
+ ed.setPosition({ lineNumber: lineNumber + 1, column: 1 });
+ } else {
+ // continue the option comment on the next line
+ ed.trigger("keyboard", "type", { text: "\n" });
+ const pos = ed.getPosition()!;
+ ed.executeEdits("quarto", [{
+ range: monaco.Range.fromPositions(pos, pos),
+ text: optionComment
+ }]);
+ ed.setPosition({ lineNumber: pos.lineNumber, column: pos.column + optionComment.length });
+ }
+ }
+ });
+ },
+
+ pmUpdate: (_prevNode, updateNode) => {
+ updateLanguage(updateNode);
+ }
+ };
+}
+
+function currentLine(editor: CodeEditor): string | undefined {
+ const model = editor.getModel();
+ const pos = editor.getPosition();
+ if (model && pos) {
+ return model.getLineContent(pos.lineNumber);
+ }
+ return undefined;
+}
diff --git a/packages/editor-monaco/src/index.ts b/packages/editor-monaco/src/index.ts
new file mode 100644
index 000000000..e95344489
--- /dev/null
+++ b/packages/editor-monaco/src/index.ts
@@ -0,0 +1,63 @@
+/*
+ * index.ts
+ *
+ * Copyright (C) 2026 by Posit Software, PBC
+ */
+
+import { Plugin, PluginKey } from "prosemirror-state";
+import { Node as ProsemirrorNode } from "prosemirror-model";
+import { EditorView, NodeView } from "prosemirror-view";
+
+import { ExtensionFn, CodeViewOptions, BaseKey, codeViewArrowHandler, CodeEditorNodeViews } from "editor";
+
+import { monacoNodeView } from "./nodeview";
+
+import "./styles.css";
+
+export const monacoPluginKey = new PluginKey("monaco");
+
+export function monacoExtension(
+ codeViews: { [key: string]: CodeViewOptions })
+: ExtensionFn {
+ return (context) => {
+
+ // shared
+ const codeNodeViews = new CodeEditorNodeViews();
+
+ // build nodeViews
+ const nodeTypes = Object.keys(codeViews);
+ const nodeViews: {
+ [name: string]: (
+ node: ProsemirrorNode,
+ view: EditorView,
+ getPos: () => number | undefined
+ ) => NodeView;
+ } = {};
+ nodeTypes.forEach((name) => {
+ nodeViews[name] = monacoNodeView(context, codeViews[name], codeNodeViews);
+ });
+
+ // return plugin
+ return {
+ plugins: () => [
+ new Plugin({
+ key: monacoPluginKey,
+ props: {
+ nodeViews,
+ handleDOMEvents: {
+ click: codeNodeViews.handleClick.bind(codeNodeViews),
+ },
+ },
+ }),
+ ],
+ baseKeys: () => {
+ return [
+ { key: BaseKey.ArrowLeft, command: codeViewArrowHandler('left', nodeTypes) },
+ { key: BaseKey.ArrowRight, command: codeViewArrowHandler('right', nodeTypes) },
+ { key: BaseKey.ArrowUp, command: codeViewArrowHandler('up', nodeTypes) },
+ { key: BaseKey.ArrowDown, command: codeViewArrowHandler('down', nodeTypes) }
+ ];
+ },
+ };
+ };
+}
diff --git a/packages/editor-monaco/src/languages/index.ts b/packages/editor-monaco/src/languages/index.ts
new file mode 100644
index 000000000..16e2745c2
--- /dev/null
+++ b/packages/editor-monaco/src/languages/index.ts
@@ -0,0 +1,67 @@
+/*
+ * index.ts
+ *
+ * Copyright (C) 2026 by Posit Software, PBC
+ *
+ * Map quarto code block languages to the language ids registered with
+ * monaco (which are the ids of the languages the host contributes).
+ */
+
+import { contributedLanguages } from "../textmate";
+
+export const kPlainTextLanguage = "plaintext";
+
+// quarto / pandoc language names that differ from the vscode language id
+const kLanguageAliases: Record = {
+ "yaml-frontmatter": "yaml",
+ yml: "yaml",
+ ojs: "javascript",
+ js: "javascript",
+ ts: "typescript",
+ sh: "shellscript",
+ bash: "shellscript",
+ zsh: "shellscript",
+ py: "python",
+ jl: "julia",
+ "c++": "cpp",
+ md: "markdown",
+ tex: "latex",
+ rs: "rust",
+ rb: "ruby",
+ ps1: "powershell",
+ pwsh: "powershell"
+};
+
+/**
+ * Resolve a quarto code block language to a monaco language id (plaintext
+ * if the host doesn't have a grammar for the language)
+ */
+export function monacoLanguageId(lang: string): string {
+ const candidate = (kLanguageAliases[lang] ?? lang).toLowerCase();
+ if (!candidate) {
+ return kPlainTextLanguage;
+ }
+
+ const languages = contributedLanguages().filter(language => !!language.scopeName);
+
+ const byId = languages.find(language => language.id.toLowerCase() === candidate);
+ if (byId) {
+ return byId.id;
+ }
+
+ const byAlias = languages.find(language =>
+ language.aliases.some(alias => alias.toLowerCase() === candidate)
+ );
+ if (byAlias) {
+ return byAlias.id;
+ }
+
+ const byExtension = languages.find(language =>
+ language.extensions.some(ext => ext.toLowerCase() === `.${candidate}`)
+ );
+ if (byExtension) {
+ return byExtension.id;
+ }
+
+ return kPlainTextLanguage;
+}
diff --git a/packages/editor-monaco/src/monaco.ts b/packages/editor-monaco/src/monaco.ts
new file mode 100644
index 000000000..070eb0805
--- /dev/null
+++ b/packages/editor-monaco/src/monaco.ts
@@ -0,0 +1,51 @@
+/*
+ * monaco.ts
+ *
+ * Copyright (C) 2026 by Posit Software, PBC
+ *
+ * One-time global setup for the Monaco editor: the editor API, the subset of
+ * editor features we use, and the web worker used by Monaco's editor services.
+ */
+
+///
+
+import * as monaco from "monaco-editor/editor/editor.api";
+
+// editor features (each import registers a contribution)
+import "monaco-editor/features/codeEditor/register";
+import "monaco-editor/features/suggest/register";
+import "monaco-editor/features/hover/register";
+import "monaco-editor/features/snippet/register";
+import "monaco-editor/features/bracketMatching/register";
+import "monaco-editor/features/indentation/register";
+import "monaco-editor/features/wordOperations/register";
+import "monaco-editor/features/wordPartOperations/register";
+import "monaco-editor/features/linesOperations/register";
+import "monaco-editor/features/clipboard/register";
+import "monaco-editor/features/cursorUndo/register";
+import "monaco-editor/features/smartSelect/register";
+import "monaco-editor/features/wordHighlighter/register";
+import "monaco-editor/features/multicursor/register";
+import "monaco-editor/features/comment/register";
+import "monaco-editor/features/caretOperations/register";
+import "monaco-editor/features/dnd/register";
+import "monaco-editor/features/tokenization/register";
+
+// the editor worker, inlined into the bundle as a blob url so that it can be
+// created inside a vscode webview (whose resources live on a different origin)
+import EditorWorker from "monaco-editor/editor/editor.worker?worker&inline";
+
+export { monaco };
+
+let initialized = false;
+
+export function initMonaco() {
+ if (initialized) {
+ return;
+ }
+ initialized = true;
+
+ self.MonacoEnvironment = {
+ getWorker: () => new EditorWorker()
+ };
+}
diff --git a/packages/editor-monaco/src/nodeview.ts b/packages/editor-monaco/src/nodeview.ts
new file mode 100644
index 000000000..af8258c86
--- /dev/null
+++ b/packages/editor-monaco/src/nodeview.ts
@@ -0,0 +1,353 @@
+/*
+ * nodeview.ts
+ *
+ * Copyright (C) 2026 by Posit Software, PBC
+ *
+ * ProseMirror NodeView that hosts a Monaco editor for a code node. The
+ * ProseMirror document is the source of truth: user edits in Monaco are
+ * forwarded to ProseMirror as transactions, and ProseMirror updates (undo,
+ * collaboration, find/replace, etc.) are applied back to the Monaco model.
+ */
+
+import { Node } from "prosemirror-model";
+import { EditorView as PMEditorView, NodeView } from "prosemirror-view";
+import { TextSelection } from "prosemirror-state";
+
+import {
+ CodeEditorNodeView,
+ CodeEditorNodeViews,
+ CodeViewOptions,
+ ExtensionContext
+} from "editor";
+
+import { monaco, initMonaco } from "./monaco";
+
+import {
+ createBehaviors,
+ behaviorOptions,
+ behaviorInit,
+ behaviorPmUpdate,
+ behaviorContentChanged,
+ behaviorCleanup,
+ State,
+ CodeEditor
+} from "./behaviors";
+
+const kBaseOptions: monaco.editor.IStandaloneEditorConstructionOptions = {
+ // layout: the editor grows to fit its content and never scrolls itself
+ automaticLayout: true,
+ scrollBeyondLastLine: false,
+ scrollbar: {
+ vertical: "hidden",
+ horizontal: "hidden",
+ alwaysConsumeMouseWheel: false,
+ handleMouseWheel: false
+ },
+ wordWrap: "on",
+ wrappingIndent: "none",
+ // chrome: strip everything that isn't the text
+ minimap: { enabled: false },
+ lineNumbers: "off",
+ glyphMargin: false,
+ folding: false,
+ lineDecorationsWidth: 0,
+ lineNumbersMinChars: 0,
+ overviewRulerLanes: 0,
+ overviewRulerBorder: false,
+ hideCursorInOverviewRuler: true,
+ renderLineHighlight: "none",
+ contextmenu: false,
+ stickyScroll: { enabled: false },
+ // popups (suggest, hover) must escape the editor's clipped box
+ fixedOverflowWidgets: true,
+ // misc
+ links: false,
+ occurrencesHighlight: "off",
+ selectionHighlight: false,
+ renderWhitespace: "none",
+ padding: { top: 4, bottom: 4 },
+ unicodeHighlight: { ambiguousCharacters: false, invisibleCharacters: false }
+};
+
+export const monacoNodeView: (
+ context: ExtensionContext,
+ codeViewOptions: CodeViewOptions,
+ nodeViews: CodeEditorNodeViews
+) => (
+ pmNode: Node,
+ view: PMEditorView,
+ getPos: () => number | undefined
+) => NodeView = (context, codeViewOptions, nodeViews) => (pmNode, view, getPos) => {
+
+ initMonaco();
+
+ // track node
+ let node = pmNode;
+
+ // scoped state and function to allow behaviors to set it
+ let updating = false;
+ let escaping = false;
+ const withState = (state: State, fn: () => void) => {
+ const setState = (value: boolean) => {
+ if (state === State.Updating) updating = value;
+ if (state === State.Escaping) escaping = value;
+ };
+ setState(true);
+ try {
+ fn();
+ } finally {
+ setState(false);
+ }
+ };
+
+ // gap cursor pending state (set by the global nodeview click handler)
+ let gapCursorPending = false;
+ const setGapCursorPending = (pending: boolean) => {
+ gapCursorPending = pending;
+ };
+
+ // track the last user selection in this code view so we can make it
+ // sticky for when prosemirror calls setSelection (e.g. on a refocus,
+ // where by default it passes 0,0)
+ let lastUserSelection: { anchor: number, head: number } | undefined;
+
+ // setup dom
+ const dom = document.createElement("div");
+ dom.classList.add("pm-code-editor");
+ dom.classList.add("pm-monaco-editor");
+ dom.classList.add(codeViewOptions.borderColorClass || "pm-block-border-color");
+ if (codeViewOptions.classes) {
+ codeViewOptions.classes.forEach(className => dom.classList.add(className));
+ }
+ const editorDom = document.createElement("div");
+ editorDom.classList.add("pm-monaco-editor-container");
+ dom.appendChild(editorDom);
+
+ // create behaviors
+ const behaviors = createBehaviors({
+ dom,
+ view,
+ getPos,
+ options: codeViewOptions,
+ pmContext: context,
+ withState
+ });
+
+ // create model and editor
+ const model = monaco.editor.createModel(node.textContent);
+ model.setEOL(monaco.editor.EndOfLineSequence.LF);
+ const editor: CodeEditor = monaco.editor.create(editorDom, {
+ ...kBaseOptions,
+ ...behaviorOptions(behaviors),
+ model
+ });
+
+ // size the editor to its content
+ const layout = () => {
+ const height = editor.getContentHeight();
+ editorDom.style.height = `${height}px`;
+ editor.layout({ width: editorDom.clientWidth, height });
+ };
+ editor.onDidContentSizeChange(layout);
+ layout();
+
+ // forward user edits to prosemirror
+ editor.onDidChangeModelContent((ev) => {
+ if (!updating) {
+ valueChanged(modelText(model), node, getPos, view);
+ forwardSelection(editor, view, getPos);
+ trackUserSelection();
+ }
+ behaviorContentChanged(behaviors, ev, editor, node);
+ });
+
+ // forward user selection changes to prosemirror
+ const trackUserSelection = () => {
+ const sel = editor.getSelection();
+ if (sel) {
+ const anchor = model.getOffsetAt(sel.getSelectionStart());
+ const head = model.getOffsetAt(sel.getPosition());
+ if (anchor !== 0 || head !== 0) {
+ lastUserSelection = { anchor, head };
+ }
+ }
+ };
+ editor.onDidChangeCursorSelection(() => {
+ if (!updating) {
+ forwardSelection(editor, view, getPos);
+ trackUserSelection();
+ }
+ });
+
+ // initialize behaviors
+ behaviorInit(behaviors, node, editor);
+
+ // track node view (this is done both so the outer editor can direct
+ // commands at the currently active nodeview, as well as for click
+ // detection between views for gap cursor handling)
+ const codeNodeView: CodeEditorNodeView = {
+ isFocused: () => editor.hasTextFocus(),
+ getPos: () => getPos() ?? 0,
+ dom,
+ setGapCursorPending
+ };
+ nodeViews.add(codeNodeView);
+
+ // helper to set the monaco selection from prosemirror offsets
+ const setEditorSelection = (anchor: number, head: number) => {
+ const anchorPos = model.getPositionAt(anchor);
+ const headPos = model.getPositionAt(head);
+ withState(State.Updating, () => {
+ editor.setSelection(monaco.Selection.fromPositions(anchorPos, headPos));
+ });
+ };
+
+ return {
+
+ dom,
+
+ selectNode() {
+ editor.focus();
+ },
+
+ stopEvent: () => true,
+
+ setSelection: (anchor, head) => {
+
+ // if prosemirror attempts to set us to 0,0 (which it does on focus)
+ // just restore our last user selection
+ if (anchor === 0 && head === 0 && lastUserSelection) {
+ anchor = lastUserSelection.anchor;
+ head = lastUserSelection.head;
+ }
+
+ // reflect the prosemirror selection change into monaco (do this before
+ // forwarding, otherwise we'd forward monaco's stale selection back to
+ // prosemirror and clobber the one it just asked for)
+ setEditorSelection(anchor, head);
+
+ // set focus and forward selection unless our state precludes this (the
+ // forward only matters if we restored a sticky user selection above)
+ if (!escaping && !gapCursorPending) {
+ editor.focus();
+ forwardSelection(editor, view, getPos);
+ }
+ },
+
+ update: (updateNode) => {
+
+ // if the node type changed, no update (standard prosemirror boilerplate)
+ if (updateNode.type.name !== node.type.name) {
+ return false;
+ }
+
+ // update node (track prevNode to pass to behaviors)
+ const prevNode = node;
+ node = updateNode;
+
+ // apply change from update node
+ const change = computeChange(modelText(model), node.textContent);
+ if (change) {
+ withState(State.Updating, () => {
+ const range = monaco.Range.fromPositions(
+ model.getPositionAt(change.from),
+ model.getPositionAt(change.to)
+ );
+ model.applyEdits([{ range, text: change.text }]);
+ const cursor = model.getPositionAt(change.from + change.text.length);
+ editor.setSelection(monaco.Selection.fromPositions(cursor, cursor));
+ });
+ }
+
+ // trigger update for behaviors
+ behaviorPmUpdate(behaviors, prevNode, updateNode, editor);
+
+ // success
+ return true;
+ },
+
+ ignoreMutation: () => true,
+
+ destroy: () => {
+ behaviorCleanup(behaviors);
+ nodeViews.remove(codeNodeView);
+ editor.dispose();
+ model.dispose();
+ },
+ };
+};
+
+const modelText = (model: monaco.editor.ITextModel) =>
+ model.getValue(monaco.editor.EndOfLinePreference.LF);
+
+const computeChange = (oldVal: string, newVal: string) => {
+ if (oldVal === newVal) return null;
+ let start = 0;
+ let oldEnd = oldVal.length;
+ let newEnd = newVal.length;
+ while (
+ start < oldEnd &&
+ oldVal.charCodeAt(start) === newVal.charCodeAt(start)
+ )
+ start += 1;
+ while (
+ oldEnd > start &&
+ newEnd > start &&
+ oldVal.charCodeAt(oldEnd - 1) === newVal.charCodeAt(newEnd - 1)
+ ) {
+ oldEnd -= 1;
+ newEnd -= 1;
+ }
+ return { from: start, to: oldEnd, text: newVal.slice(start, newEnd) };
+};
+
+const asProseMirrorSelection = (
+ pmDoc: Node,
+ editor: CodeEditor,
+ getPos: () => number | undefined
+) => {
+ const model = editor.getModel()!;
+ const sel = editor.getSelection()!;
+ const offset = (getPos() ?? 0) + 1;
+ const anchor = model.getOffsetAt(sel.getSelectionStart()) + offset;
+ const head = model.getOffsetAt(sel.getPosition()) + offset;
+ return TextSelection.create(pmDoc, anchor, head);
+};
+
+const forwardSelection = (
+ editor: CodeEditor,
+ pmView: PMEditorView,
+ getPos: () => number | undefined
+) => {
+ if (!editor.hasTextFocus()) return;
+ const selection = asProseMirrorSelection(pmView.state.doc, editor, getPos);
+ if (!selection.eq(pmView.state.selection))
+ pmView.dispatch(pmView.state.tr.setSelection(selection));
+};
+
+const valueChanged = (
+ textUpdate: string,
+ node: Node,
+ getPos: () => number | undefined,
+ view: PMEditorView
+) => {
+ const change = computeChange(node.textContent, textUpdate);
+ const pos = getPos();
+ if (change && pos !== undefined) {
+ const start = pos + 1;
+ const pmTr = view.state.tr;
+ if (change.text) {
+ pmTr.replaceWith(
+ start + change.from,
+ start + change.to,
+ view.state.schema.text(change.text)
+ );
+ } else {
+ pmTr.deleteRange(
+ start + change.from,
+ start + change.to
+ );
+ }
+ view.dispatch(pmTr);
+ }
+};
diff --git a/packages/editor-monaco/src/styles.css b/packages/editor-monaco/src/styles.css
new file mode 100644
index 000000000..62465c73a
--- /dev/null
+++ b/packages/editor-monaco/src/styles.css
@@ -0,0 +1,63 @@
+/*
+ * styles.css
+ *
+ * Copyright (C) 2026 by Posit Software, PBC
+ */
+
+.pm-monaco-editor {
+ position: relative;
+}
+
+.pm-monaco-editor .pm-monaco-editor-container {
+ width: 100%;
+}
+
+/* per-editor background (monaco's theme background is global) */
+.pm-monaco-editor.pm-monaco-editor-background .monaco-editor,
+.pm-monaco-editor.pm-monaco-editor-background .monaco-editor .monaco-editor-background,
+.pm-monaco-editor.pm-monaco-editor-background .monaco-editor .margin {
+ background-color: var(--pm-code-background);
+}
+
+/* the first line of a chunk is metadata (e.g. {r}) and is rendered lighter */
+.pm-monaco-editor .pm-monaco-first-line-meta {
+ opacity: 0.6;
+}
+
+/* no extraneous monaco ui: the find widget is pulled in by the multicursor
+ feature (cmd+d) and would otherwise appear inside the cell */
+.pm-monaco-editor .monaco-editor .find-widget,
+.pm-monaco-editor .monaco-editor .quick-input-widget {
+ display: none !important;
+}
+
+/* the editor never scrolls itself; the outer document does */
+.pm-monaco-editor .monaco-editor,
+.pm-monaco-editor .monaco-editor .overflow-guard {
+ overflow: visible;
+}
+
+.pm-monaco-editor .pm-monaco-toolbar {
+ position: absolute;
+ top: 0;
+ right: 0;
+ z-index: 9;
+ margin-top: 3px;
+ margin-right: 3px;
+}
+
+.pm-monaco-editor .pm-monaco-toolbar .codicon {
+ font-size: 14px;
+ cursor: pointer;
+ user-select: none;
+}
+
+.pm-codeview-run-button {
+ margin-left: 9px;
+ font-weight: 500 !important;
+}
+
+.pm-codeview-run-other-button {
+ margin-left: 6px;
+ opacity: 0.7;
+}
diff --git a/packages/editor-monaco/src/textmate.ts b/packages/editor-monaco/src/textmate.ts
new file mode 100644
index 000000000..d58a7cf1c
--- /dev/null
+++ b/packages/editor-monaco/src/textmate.ts
@@ -0,0 +1,369 @@
+/*
+ * textmate.ts
+ *
+ * Copyright (C) 2026 by Posit Software, PBC
+ *
+ * TextMate tokenization and VS Code color themes for Monaco. The grammars,
+ * languages, and the active color theme all come from the host (the vscode
+ * extension), so code cells are highlighted exactly as the source editor is.
+ */
+
+///
+
+import * as vsctm from "vscode-textmate";
+import * as onig from "vscode-oniguruma";
+import onigWasmUrl from "vscode-oniguruma/release/onig.wasm?url";
+
+import {
+ CodeViewColorTheme,
+ CodeViewLanguage,
+ CodeViewServer
+} from "editor-types";
+
+import { monaco } from "./monaco";
+
+const kThemeName = "vscode-active";
+
+let registry: vsctm.Registry | undefined;
+let languages: CodeViewLanguage[] = [];
+let initPromise: Promise | undefined;
+let themeColors: Record = {};
+
+/**
+ * One time initialization of the textmate registry and registration of the
+ * host's languages with monaco. Safe to call repeatedly (returns the same
+ * promise). Resolves (never rejects) once languages are available.
+ */
+export function initTextMate(codeview: CodeViewServer | undefined): Promise {
+ if (!initPromise) {
+ initPromise = codeview
+ ? doInit(codeview).catch(error => {
+ console.error("Error initializing textmate tokenization", error);
+ })
+ : Promise.resolve();
+ }
+ return initPromise;
+}
+
+/**
+ * Languages contributed by the host (empty until initTextMate resolves)
+ */
+export function contributedLanguages(): CodeViewLanguage[] {
+ return languages;
+}
+
+/**
+ * Workbench colors of the active color theme (empty if no theme applied)
+ */
+export function activeThemeColors(): Record {
+ return themeColors;
+}
+
+/**
+ * Apply a VS Code color theme to monaco (all editors share the theme).
+ */
+export function applyColorTheme(theme: CodeViewColorTheme | undefined, darkMode: boolean) {
+
+ if (!theme || !registry) {
+ themeColors = {};
+ monaco.editor.setTheme(darkMode ? "vs-dark" : "vs");
+ return;
+ }
+
+ themeColors = theme.colors;
+
+ const base = themeBase(theme.type);
+ const dark = base !== "vs" && base !== "hc-light";
+ const foreground = theme.colors["editor.foreground"] || (dark ? "#D4D4D4" : "#000000");
+ const background = theme.colors["editor.background"] || (dark ? "#1E1E1E" : "#FFFFFF");
+
+ // set the textmate theme (the color map it produces is what token colors
+ // are encoded against)
+ registry.setTheme({
+ name: theme.name,
+ settings: [
+ { settings: { foreground: normalizeHex(foreground), background: normalizeHex(background) } },
+ ...theme.tokenColors.map(rule => ({
+ ...rule,
+ settings: {
+ ...rule.settings,
+ foreground: normalizeHex(rule.settings.foreground),
+ background: normalizeHex(rule.settings.background)
+ }
+ }))
+ ]
+ });
+ // monaco wants null (not undefined) for the unused color id 0
+ const colorMap = registry.getColorMap() as Array;
+ colorMap[0] = null;
+
+ monaco.editor.defineTheme(kThemeName, {
+ base,
+ inherit: false,
+ rules: [],
+ colors: validColors(theme.colors),
+ encodedTokensColors: colorMap as string[]
+ });
+ monaco.editor.setTheme(kThemeName);
+
+ // token color ids may have changed, so re-tokenize all models
+ for (const model of monaco.editor.getModels()) {
+ const languageId = model.getLanguageId();
+ if (languageId !== "plaintext") {
+ monaco.editor.setModelLanguage(model, "plaintext");
+ monaco.editor.setModelLanguage(model, languageId);
+ }
+ }
+}
+
+async function doInit(codeview: CodeViewServer) {
+
+ // load oniguruma
+ await onig.loadWASM(await loadOnigWasm());
+ const onigLib = Promise.resolve({
+ createOnigScanner: (patterns) => new onig.OnigScanner(patterns),
+ createOnigString: (str) => new onig.OnigString(str)
+ });
+
+ // create registry (grammars are provided by the host)
+ registry = new vsctm.Registry({
+ onigLib,
+ loadGrammar: async (scopeName) => {
+ const grammar = await codeview.codeViewGrammar(scopeName);
+ return grammar ? vsctm.parseRawGrammar(grammar.content, grammar.path) : null;
+ }
+ });
+
+ // register languages
+ languages = await codeview.codeViewLanguages();
+ const registered = new Set(monaco.languages.getLanguages().map(lang => lang.id));
+ for (const language of languages) {
+ if (!language.scopeName || registered.has(language.id)) {
+ continue;
+ }
+ registered.add(language.id);
+ monaco.languages.register({
+ id: language.id,
+ aliases: language.aliases,
+ extensions: language.extensions
+ });
+ const scopeName = language.scopeName;
+ monaco.languages.registerTokensProviderFactory(language.id, {
+ create: async () => {
+ const grammar = await registry!.loadGrammar(scopeName);
+ return grammar ? tokensProvider(grammar) : null;
+ }
+ });
+ if (language.configuration) {
+ monaco.languages.setLanguageConfiguration(
+ language.id,
+ languageConfiguration(language.configuration as LanguageConfigurationJson)
+ );
+ }
+ }
+}
+
+// in lib mode vite inlines the wasm as a data url, otherwise it's a real url
+async function loadOnigWasm(): Promise {
+ const marker = ";base64,";
+ const idx = onigWasmUrl.indexOf(marker);
+ if (onigWasmUrl.startsWith("data:") && idx !== -1) {
+ const binary = atob(onigWasmUrl.slice(idx + marker.length));
+ const bytes = new Uint8Array(binary.length);
+ for (let i = 0; i < binary.length; i++) {
+ bytes[i] = binary.charCodeAt(i);
+ }
+ return bytes.buffer;
+ } else {
+ const response = await fetch(onigWasmUrl);
+ return response.arrayBuffer();
+ }
+}
+
+// monaco encoded tokens provider backed by a textmate grammar (the binary
+// token format produced by tokenizeLine2 is the one monaco consumes)
+class TokenizerState implements monaco.languages.IState {
+ constructor(public readonly ruleStack: vsctm.StateStack) { }
+ clone() {
+ return new TokenizerState(this.ruleStack);
+ }
+ equals(other: monaco.languages.IState) {
+ return other instanceof TokenizerState && other.ruleStack === this.ruleStack;
+ }
+}
+
+function tokensProvider(grammar: vsctm.IGrammar): monaco.languages.EncodedTokensProvider {
+ return {
+ getInitialState: () => new TokenizerState(vsctm.INITIAL),
+ tokenizeEncoded: (line, state) => {
+ const result = grammar.tokenizeLine2(line, (state as TokenizerState).ruleStack);
+ return {
+ tokens: result.tokens,
+ endState: new TokenizerState(result.ruleStack)
+ };
+ }
+ };
+}
+
+function themeBase(type: CodeViewColorTheme["type"]): monaco.editor.BuiltinTheme {
+ switch (type) {
+ case "light":
+ return "vs";
+ case "hcDark":
+ return "hc-black";
+ case "hcLight":
+ return "hc-light";
+ case "dark":
+ default:
+ return "vs-dark";
+ }
+}
+
+// monaco's token color map only accepts #RRGGBB or #RRGGBBAA
+function normalizeHex(color?: string): string | undefined {
+ if (!color) {
+ return undefined;
+ }
+ const match = color.trim().match(/^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i);
+ if (!match) {
+ return undefined;
+ }
+ const hex = match[1];
+ if (hex.length === 3 || hex.length === 4) {
+ return "#" + hex.split("").map(c => c + c).join("");
+ }
+ return "#" + hex;
+}
+
+// monaco only accepts hex colors
+function validColors(colors: Record) {
+ const valid: Record = {};
+ for (const [id, color] of Object.entries(colors)) {
+ if (typeof color === "string" && /^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i.test(color)) {
+ valid[id] = color;
+ }
+ }
+ return valid;
+}
+
+// convert a vscode language-configuration.json into a monaco LanguageConfiguration
+
+type RegExpJson = string | { pattern: string; flags?: string };
+
+interface LanguageConfigurationJson {
+ comments?: { lineComment?: string; blockComment?: [string, string] };
+ brackets?: Array<[string, string]>;
+ autoClosingPairs?: Array<[string, string] | { open: string; close: string; notIn?: string[] }>;
+ surroundingPairs?: Array<[string, string] | { open: string; close: string }>;
+ wordPattern?: RegExpJson;
+ indentationRules?: {
+ increaseIndentPattern?: RegExpJson;
+ decreaseIndentPattern?: RegExpJson;
+ indentNextLinePattern?: RegExpJson;
+ unIndentedLinePattern?: RegExpJson;
+ };
+ folding?: {
+ offSide?: boolean;
+ markers?: { start?: RegExpJson; end?: RegExpJson };
+ };
+ onEnterRules?: Array<{
+ beforeText?: RegExpJson;
+ afterText?: RegExpJson;
+ previousLineText?: RegExpJson;
+ action?: { indent?: string; appendText?: string; removeText?: number };
+ }>;
+}
+
+function languageConfiguration(json: LanguageConfigurationJson): monaco.languages.LanguageConfiguration {
+ const config: monaco.languages.LanguageConfiguration = {};
+
+ if (json.comments) {
+ config.comments = json.comments;
+ }
+ if (json.brackets) {
+ config.brackets = json.brackets;
+ }
+ if (json.autoClosingPairs) {
+ config.autoClosingPairs = json.autoClosingPairs.map(pair =>
+ Array.isArray(pair) ? { open: pair[0], close: pair[1] } : pair
+ );
+ }
+ if (json.surroundingPairs) {
+ config.surroundingPairs = json.surroundingPairs.map(pair =>
+ Array.isArray(pair) ? { open: pair[0], close: pair[1] } : pair
+ );
+ }
+ const wordPattern = toRegExp(json.wordPattern);
+ if (wordPattern) {
+ config.wordPattern = wordPattern;
+ }
+ if (json.indentationRules) {
+ const increase = toRegExp(json.indentationRules.increaseIndentPattern);
+ const decrease = toRegExp(json.indentationRules.decreaseIndentPattern);
+ if (increase && decrease) {
+ config.indentationRules = {
+ increaseIndentPattern: increase,
+ decreaseIndentPattern: decrease,
+ indentNextLinePattern: toRegExp(json.indentationRules.indentNextLinePattern),
+ unIndentedLinePattern: toRegExp(json.indentationRules.unIndentedLinePattern)
+ };
+ }
+ }
+ if (json.folding) {
+ const start = toRegExp(json.folding.markers?.start);
+ const end = toRegExp(json.folding.markers?.end);
+ config.folding = {
+ offSide: json.folding.offSide,
+ markers: start && end ? { start, end } : undefined
+ };
+ }
+ if (json.onEnterRules) {
+ const rules: monaco.languages.OnEnterRule[] = [];
+ for (const rule of json.onEnterRules) {
+ const beforeText = toRegExp(rule.beforeText);
+ if (!beforeText || !rule.action) {
+ continue;
+ }
+ rules.push({
+ beforeText,
+ afterText: toRegExp(rule.afterText),
+ previousLineText: toRegExp(rule.previousLineText),
+ action: {
+ indentAction: indentAction(rule.action.indent),
+ appendText: rule.action.appendText,
+ removeText: rule.action.removeText
+ }
+ });
+ }
+ config.onEnterRules = rules;
+ }
+
+ return config;
+}
+
+function toRegExp(value?: RegExpJson): RegExp | undefined {
+ if (!value) {
+ return undefined;
+ }
+ try {
+ return typeof value === "string"
+ ? new RegExp(value)
+ : new RegExp(value.pattern, value.flags);
+ } catch {
+ return undefined;
+ }
+}
+
+function indentAction(indent?: string): monaco.languages.IndentAction {
+ switch (indent) {
+ case "indent":
+ return monaco.languages.IndentAction.Indent;
+ case "outdent":
+ return monaco.languages.IndentAction.Outdent;
+ case "indentOutdent":
+ return monaco.languages.IndentAction.IndentOutdent;
+ case "none":
+ default:
+ return monaco.languages.IndentAction.None;
+ }
+}
diff --git a/packages/editor-monaco/tsconfig.json b/packages/editor-monaco/tsconfig.json
new file mode 100644
index 000000000..aad794c26
--- /dev/null
+++ b/packages/editor-monaco/tsconfig.json
@@ -0,0 +1,5 @@
+{
+ "exclude": ["node_modules"],
+ "extends": "tsconfig/base.json",
+ "include": ["src"]
+}
diff --git a/packages/editor-server/src/services/codeview.ts b/packages/editor-server/src/services/codeview.ts
index 4b69ad01d..091cf775f 100644
--- a/packages/editor-server/src/services/codeview.ts
+++ b/packages/editor-server/src/services/codeview.ts
@@ -12,7 +12,11 @@ import {
kCodeViewExecute,
kCodeViewPreviewDiagram,
kCodeViewAssist,
- kCodeViewGetDiagnostics
+ kCodeViewGetDiagnostics,
+ kCodeViewGetColorTheme,
+ kCodeViewGetLanguages,
+ kCodeViewGetGrammar,
+ kCodeViewGetEditorFont
} from "editor-types";
@@ -23,7 +27,11 @@ export function codeViewServerMethods(server: CodeViewServer): Record server.codeViewExecute(args[0], args[1]),
[kCodeViewGetCompletions]: args => server.codeViewCompletions(args[0]),
[kCodeViewGetDiagnostics]: args => server.codeViewDiagnostics(args[0]),
- [kCodeViewPreviewDiagram]: args => server.codeViewPreviewDiagram(args[0], args[1])
+ [kCodeViewPreviewDiagram]: args => server.codeViewPreviewDiagram(args[0], args[1]),
+ [kCodeViewGetColorTheme]: args => server.codeViewColorTheme(args[0]),
+ [kCodeViewGetLanguages]: () => server.codeViewLanguages(),
+ [kCodeViewGetGrammar]: args => server.codeViewGrammar(args[0]),
+ [kCodeViewGetEditorFont]: () => server.codeViewEditorFont()
};
return methods;
}
diff --git a/packages/editor-types/src/codeview.ts b/packages/editor-types/src/codeview.ts
index 11c40032c..6c8070101 100644
--- a/packages/editor-types/src/codeview.ts
+++ b/packages/editor-types/src/codeview.ts
@@ -15,6 +15,67 @@ export const kCodeViewPreviewDiagram = 'code_view_preview_diagram';
* for calling [`codeViewDiagnostics` in custom.ts](../../../apps/lsp/src/custom.ts)
*/
export const kCodeViewGetDiagnostics = 'code_view_get_diagnostics';
+export const kCodeViewGetColorTheme = 'code_view_get_color_theme';
+export const kCodeViewGetLanguages = 'code_view_get_languages';
+export const kCodeViewGetGrammar = 'code_view_get_grammar';
+export const kCodeViewGetEditorFont = 'code_view_get_editor_font';
+
+/**
+ * The font settings of the vscode source editor (editor.fontFamily etc.)
+ */
+export interface CodeViewEditorFont {
+ fontFamily: string;
+ fontSize: number;
+ fontWeight: string;
+ fontLigatures: boolean | string;
+ lineHeight: number;
+ letterSpacing: number;
+}
+
+/**
+ * A TextMate token color rule from a VS Code color theme
+ */
+export interface CodeViewTokenColor {
+ name?: string;
+ scope?: string | string[];
+ settings: {
+ foreground?: string;
+ background?: string;
+ fontStyle?: string;
+ };
+}
+
+/**
+ * The active VS Code color theme (resolved from the theme's json, including
+ * any themes it includes). `colors` are workbench color ids (e.g. "editor.background").
+ */
+export interface CodeViewColorTheme {
+ name: string;
+ type: "light" | "dark" | "hcDark" | "hcLight";
+ colors: Record;
+ tokenColors: CodeViewTokenColor[];
+}
+
+/**
+ * A language contributed by a VS Code extension, along with the TextMate
+ * scope of its grammar (if it has one) and its language configuration
+ */
+export interface CodeViewLanguage {
+ id: string;
+ aliases: string[];
+ extensions: string[];
+ scopeName?: string;
+ configuration?: unknown;
+}
+
+/**
+ * The raw contents of a TextMate grammar (json or plist, see `path`)
+ */
+export interface CodeViewGrammar {
+ scopeName: string;
+ path: string;
+ content: string;
+}
export type CodeViewExecute = "selection" | "cell" | "cell+advance" | "above" | "below";
@@ -63,4 +124,8 @@ export interface CodeViewServer {
codeViewDiagnostics: (context: CodeViewCellContext) => Promise;
codeViewCompletions: (context: CodeViewCompletionContext) => Promise;
codeViewPreviewDiagram: (state: DiagramState, activate: boolean) => Promise;
+ codeViewColorTheme: (themeId?: string) => Promise;
+ codeViewLanguages: () => Promise;
+ codeViewGrammar: (scopeName: string) => Promise;
+ codeViewEditorFont: () => Promise;
}
diff --git a/packages/editor-ui/package.json b/packages/editor-ui/package.json
index 270f7e09f..c4c8105aa 100644
--- a/packages/editor-ui/package.json
+++ b/packages/editor-ui/package.json
@@ -13,6 +13,7 @@
"core-browser": "*",
"editor": "*",
"editor-codemirror": "*",
+ "editor-monaco": "*",
"editor-core": "*",
"editor-types": "*",
"i18next": "^26.5.4",
diff --git a/packages/editor-ui/src/context/context.ts b/packages/editor-ui/src/context/context.ts
index f8221a3ba..7204f9a6e 100644
--- a/packages/editor-ui/src/context/context.ts
+++ b/packages/editor-ui/src/context/context.ts
@@ -6,7 +6,7 @@
import { JsonRpcRequestTransport, PromiseQueue } from 'core';
-import { codeMirrorExtension } from "editor-codemirror";
+import { monacoExtension } from "editor-monaco";
import {
EditorDialogs,
@@ -70,7 +70,7 @@ export function editorContext(providers: EditorProviders) : EditorContext {
server: providers.server,
ui,
extensions: providers.extensions,
- codeViewExtension: codeMirrorExtension
+ codeViewExtension: monacoExtension
};
return context;
diff --git a/packages/editor/src/api/ui-types.ts b/packages/editor/src/api/ui-types.ts
index 03ccb7e5c..3a4a8f478 100644
--- a/packages/editor/src/api/ui-types.ts
+++ b/packages/editor/src/api/ui-types.ts
@@ -18,8 +18,12 @@ import {
CodeViewActiveBlockContext,
DiagramState,
CodeViewCellContext,
- LintItem
-} from 'editor-types';
+ LintItem,
+ CodeViewColorTheme,
+ CodeViewLanguage,
+ CodeViewGrammar,
+ CodeViewEditorFont
+} from "editor-types";
export * from './spelling';
export { SkinTone } from './emoji';
@@ -127,6 +131,10 @@ export interface EditorUICodeView {
codeViewCompletions: (context: CodeViewCompletionContext) => Promise;
codeViewDiagnostics: (context: CodeViewCellContext) => Promise;
codeViewPreviewDiagram: (state: DiagramState, activate: boolean) => Promise;
+ codeViewColorTheme: (themeId?: string) => Promise;
+ codeViewLanguages: () => Promise;
+ codeViewGrammar: (scopeName: string) => Promise;
+ codeViewEditorFont: () => Promise;
}
export const kListSpacingTight = 'tight';
diff --git a/packages/editor/src/behaviors/select_all.ts b/packages/editor/src/behaviors/select_all.ts
index d803020ff..41a35e142 100644
--- a/packages/editor/src/behaviors/select_all.ts
+++ b/packages/editor/src/behaviors/select_all.ts
@@ -34,7 +34,7 @@ export function selectAll(state: EditorState, dispatch?: (tr: Transaction) => vo
}
dispatch(tr);
if (view) {
- // we do this to escape from embedded editors e.g. codemirror
+ // we do this to escape from embedded editors e.g. monaco
view.focus();
}
}
diff --git a/packages/editor/src/nodes/rmd_chunk/rmd_chunk-image.ts b/packages/editor/src/nodes/rmd_chunk/rmd_chunk-image.ts
index 3daf8f692..3580d4099 100644
--- a/packages/editor/src/nodes/rmd_chunk/rmd_chunk-image.ts
+++ b/packages/editor/src/nodes/rmd_chunk/rmd_chunk-image.ts
@@ -84,7 +84,7 @@ function imagePreviewDecorations(state: EditorState, uiContext: EditorUIContext)
rmdChunk.pos + rmdChunk.node.nodeSize,
(view: EditorView, getPos: () => number | undefined) => {
const container = window.document.createElement('div');
- container.style.marginTop = '-1.5em'; // to bridge back to the codemirror block
+ container.style.marginTop = '-1.5em'; // to bridge back to the code block
// which has a margin-block-end of 1em
container.classList.add('pm-image-preview');
container.classList.add('pm-block-border-color');