diff --git a/apps/next/src/app/layout.tsx b/apps/next/src/app/layout.tsx
index 7c0d8d2..4afb928 100644
--- a/apps/next/src/app/layout.tsx
+++ b/apps/next/src/app/layout.tsx
@@ -1,5 +1,5 @@
import type { Metadata, Viewport } from 'next';
-import { Geist, Geist_Mono } from 'next/font/google';
+import { Geist, Geist_Mono, Victor_Mono } from 'next/font/google';
import { env } from '@/env';
import '@/app/styles.css';
@@ -30,6 +30,13 @@ const geistMono = Geist_Mono({
subsets: ['latin'],
variable: '--font-geist-mono',
});
+// Used by the workspace code editor (and, later, the terminal). Includes the
+// italic cursive style for comments via Monaco's italic token styling.
+const victorMono = Victor_Mono({
+ subsets: ['latin'],
+ variable: '--font-victor-mono',
+ display: 'swap',
+});
const RootLayout = ({
children,
@@ -44,7 +51,7 @@ const RootLayout = ({
>
await import('@monaco-editor/react'), {
ssr: false,
});
+const EDITOR_FONT_FAMILY =
+ "var(--font-victor-mono), 'Geist Mono', ui-monospace, SFMono-Regular, monospace";
+
type MonacoEditorInstance = {
getModel?: () => unknown;
};
@@ -42,6 +53,8 @@ export const CodeEditor = ({
const editorRef = useRef(null);
const vimRef = useRef(null);
const statusRef = useRef(null);
+ const { resolvedTheme } = useTheme();
+ const editorTheme = resolvedTheme === 'light' ? SPOON_LIGHT : SPOON_DARK;
useEffect(() => {
const editor = editorRef.current;
@@ -115,14 +128,23 @@ export const CodeEditor = ({
path={path}
language={languageForPath(path)}
value={content}
- theme='vs-dark'
+ theme={editorTheme}
+ beforeMount={(monaco) => {
+ configureSpoonMonaco(monaco as unknown as MonacoLike);
+ }}
options={{
readOnly,
minimap: { enabled: false },
+ fontFamily: EDITOR_FONT_FAMILY,
+ fontLigatures: true,
fontSize: 13,
+ lineHeight: 1.6,
scrollBeyondLastLine: false,
wordWrap: 'on',
automaticLayout: true,
+ smoothScrolling: true,
+ cursorSmoothCaretAnimation: 'on',
+ padding: { top: 12, bottom: 12 },
scrollbar: { alwaysConsumeMouseWheel: false },
quickSuggestions: true,
suggestOnTriggerCharacters: true,
@@ -131,8 +153,9 @@ export const CodeEditor = ({
bracketPairColorization: { enabled: true },
renderWhitespace: 'selection',
}}
- onMount={(editor) => {
+ onMount={(editor, monaco) => {
editorRef.current = editor as MonacoEditorInstance;
+ remeasureFontsWhenReady(monaco as unknown as MonacoLike);
}}
onChange={(next) => {
const nextValue = next ?? '';
diff --git a/apps/next/src/components/agent-workspace/monaco-theme.ts b/apps/next/src/components/agent-workspace/monaco-theme.ts
new file mode 100644
index 0000000..9aad274
--- /dev/null
+++ b/apps/next/src/components/agent-workspace/monaco-theme.ts
@@ -0,0 +1,163 @@
+export const SPOON_DARK = 'spoon-dark';
+export const SPOON_LIGHT = 'spoon-light';
+
+type ThemeRule = { token: string; foreground?: string; fontStyle?: string };
+type ThemeData = {
+ base: 'vs' | 'vs-dark';
+ inherit: boolean;
+ rules: ThemeRule[];
+ colors: Record;
+};
+type DiagnosticsDefaults = {
+ setDiagnosticsOptions: (options: {
+ noSemanticValidation?: boolean;
+ noSyntaxValidation?: boolean;
+ noSuggestionDiagnostics?: boolean;
+ }) => void;
+};
+
+// Minimal typed surface of the bits of the Monaco namespace we touch. Avoids
+// depending on monaco-editor's full (and, under our eslint program, unresolved)
+// type graph while keeping these calls fully type-checked.
+export type MonacoLike = {
+ editor: {
+ defineTheme: (name: string, data: ThemeData) => void;
+ remeasureFonts: () => void;
+ };
+ languages: {
+ typescript: {
+ typescriptDefaults: DiagnosticsDefaults;
+ javascriptDefaults: DiagnosticsDefaults;
+ };
+ };
+};
+
+// Hex equivalents of the site's oklch design tokens (tools/tailwind/theme.css),
+// so the editor matches the rest of the app. Brand accent is the teal --primary.
+const dark = {
+ bg: '#080e14', // --background
+ surface: '#10171e', // --card
+ surfaceAlt: '#192028', // --muted
+ border: '#29313a', // --border
+ fg: '#eef3f5', // --foreground
+ fgDim: '#cdd6dc',
+ muted: '#93a1a9', // --muted-foreground
+ comment: '#6b7d88',
+ teal: '#1fb895', // --primary
+ mint: '#8fd6b4',
+ cyan: '#5fd0e0',
+ blue: '#6aa6ff',
+ amber: '#e3b341',
+ red: '#f3625d', // --destructive
+};
+
+const light = {
+ bg: '#f7fbfa', // --background
+ surface: '#ffffff', // --card
+ surfaceAlt: '#eaeff3', // --muted
+ border: '#d4dce2', // --border
+ fg: '#0d1218', // --foreground
+ fgDim: '#26323c',
+ muted: '#555f68', // --muted-foreground
+ comment: '#6b7680',
+ teal: '#007560', // --primary
+ mint: '#2f8f6e',
+ cyan: '#0f7d92',
+ blue: '#2f6bd8',
+ amber: '#9a6b00',
+ red: '#d73337', // --destructive
+};
+
+const hex = (value: string) => value.slice(1);
+
+const themeData = (p: typeof dark, base: 'vs' | 'vs-dark'): ThemeData => ({
+ base,
+ inherit: true,
+ rules: [
+ { token: '', foreground: hex(p.fg) },
+ { token: 'comment', foreground: hex(p.comment), fontStyle: 'italic' },
+ { token: 'keyword', foreground: hex(p.teal) },
+ { token: 'keyword.control', foreground: hex(p.teal) },
+ { token: 'storage', foreground: hex(p.teal) },
+ { token: 'string', foreground: hex(p.mint) },
+ { token: 'string.key.json', foreground: hex(p.cyan) },
+ { token: 'string.value.json', foreground: hex(p.mint) },
+ { token: 'number', foreground: hex(p.amber) },
+ { token: 'constant', foreground: hex(p.amber) },
+ { token: 'regexp', foreground: hex(p.amber) },
+ { token: 'type', foreground: hex(p.cyan) },
+ { token: 'type.identifier', foreground: hex(p.cyan) },
+ { token: 'interface', foreground: hex(p.cyan) },
+ { token: 'namespace', foreground: hex(p.cyan) },
+ { token: 'function', foreground: hex(p.blue) },
+ { token: 'variable', foreground: hex(p.fgDim) },
+ { token: 'variable.parameter', foreground: hex(p.fgDim) },
+ { token: 'property', foreground: hex(p.fgDim) },
+ { token: 'operator', foreground: hex(p.muted) },
+ { token: 'delimiter', foreground: hex(p.muted) },
+ { token: 'tag', foreground: hex(p.teal) },
+ { token: 'attribute.name', foreground: hex(p.amber) },
+ { token: 'attribute.value', foreground: hex(p.mint) },
+ { token: 'metatag', foreground: hex(p.teal) },
+ ],
+ colors: {
+ 'editor.background': p.bg,
+ 'editor.foreground': p.fg,
+ 'editorCursor.foreground': p.teal,
+ 'editorLineNumber.foreground': p.border,
+ 'editorLineNumber.activeForeground': p.muted,
+ 'editor.lineHighlightBackground': p.surface,
+ 'editor.selectionBackground': `${p.teal}33`,
+ 'editor.inactiveSelectionBackground': `${p.teal}22`,
+ 'editor.findMatchBackground': `${p.teal}55`,
+ 'editor.findMatchHighlightBackground': `${p.teal}33`,
+ 'editorWhitespace.foreground': p.border,
+ 'editorIndentGuide.background1': p.surfaceAlt,
+ 'editorIndentGuide.activeBackground1': p.border,
+ 'editorGutter.background': p.bg,
+ 'editorWidget.background': p.surface,
+ 'editorWidget.border': p.border,
+ 'editorHoverWidget.background': p.surface,
+ 'editorHoverWidget.border': p.border,
+ 'editorSuggestWidget.background': p.surface,
+ 'editorSuggestWidget.border': p.border,
+ 'editorSuggestWidget.selectedBackground': p.surfaceAlt,
+ 'editorBracketMatch.background': `${p.teal}22`,
+ 'editorBracketMatch.border': p.teal,
+ 'editorError.foreground': p.red,
+ 'scrollbarSlider.background': `${p.border}aa`,
+ 'scrollbarSlider.hoverBackground': p.border,
+ 'scrollbarSlider.activeBackground': p.muted,
+ },
+});
+
+let configured = false;
+
+/**
+ * Defines the site-matched editor themes and quiets the in-browser TypeScript
+ * service. Monaco's TS worker has no access to the project's node_modules or the
+ * `~`/`@` path aliases, so its semantic diagnostics (e.g. "Cannot find module
+ * '~/server/auth'") are always false positives here. We keep real syntax errors
+ * and disable the unresolvable semantic noise. Runs once per page load.
+ */
+export const configureSpoonMonaco = (monaco: MonacoLike) => {
+ monaco.editor.defineTheme(SPOON_DARK, themeData(dark, 'vs-dark'));
+ monaco.editor.defineTheme(SPOON_LIGHT, themeData(light, 'vs'));
+ if (configured) return;
+ configured = true;
+ for (const defaults of [
+ monaco.languages.typescript.typescriptDefaults,
+ monaco.languages.typescript.javascriptDefaults,
+ ]) {
+ defaults.setDiagnosticsOptions({
+ noSemanticValidation: true,
+ noSuggestionDiagnostics: true,
+ noSyntaxValidation: false,
+ });
+ }
+};
+
+/** Re-measures glyph widths once the web font finishes loading so they align. */
+export const remeasureFontsWhenReady = (monaco: MonacoLike) => {
+ void document.fonts.ready.then(() => monaco.editor.remeasureFonts());
+};