pyreon

@pyreon/code is a reactive code editor built on CodeMirror 6. Every piece of editor state — content, language, theme, cursor, selection — is a Pyreon signal, so the editor composes with the rest of your reactive app the same way any other signal does. It ships a canvas-based minimap, a side-by-side diff editor, lazy-loaded language grammars, and a two-way signal binding helper. The core editor measures ~138 KB gzipped (~416 KB minified) — about 7x lighter than Monaco's ~940 KB gzipped core, with each language grammar streaming as a ~40 KB gzipped lazy chunk (reproduce: bun run --filter=@pyreon/code bench).

@pyreon/codestable

Installation

npm install @pyreon/code
bun add @pyreon/code
pnpm add @pyreon/code
yarn add @pyreon/code

The core editor (CodeMirror state, view, search, history, lint underlines, diff) ships as regular dependencies. The language grammars are optionalDependencies — pulled in on demand by loadLanguage — so a JSON-only editor never downloads the Rust or C++ grammar.

Quick Start

import { createEditor, CodeEditor } from '@pyreon/code'

const editor = createEditor({
  value: 'const greeting = "Hello, Pyreon!"',
  language: 'typescript',
  theme: 'dark',
})

<CodeEditor instance={editor} style="height: 400px" />

createEditor builds a framework-independent EditorInstance; the <CodeEditor> component mounts the CodeMirror view into a container <div> and cleans it up on unmount. The two are split so the instance (and all its signals) can live in a store, a parent component, or a hook — independent of when it's actually mounted.

Signal-Backed State

Four pieces of editor state are writable signals, and three are computed signals derived from the live view:

// Writable signals — read reactively, write to drive the editor
editor.value() // current content (string)
editor.language() // current language
editor.theme() // current theme
editor.readOnly() // read-only state
editor.focused() // has focus (read; the editor writes it on focus/blur)

// Computed signals — derived from the live view, read-only
editor.cursor() // { line: number, col: number }  (1-based)
editor.selection() // { from: number, to: number, text: string }
editor.lineCount() // number of lines

// The underlying view, null until mounted
editor.view() // EditorView | null

Writing a writable signal pushes the change into CodeMirror through a compartment, with no remount:

editor.value.set('new content') // replaces the document
editor.language.set('python') // lazy-loads + swaps the grammar
editor.theme.set('dark') // reconfigures the theme extension
editor.readOnly.set(true) // toggles read-only

Because everything is a signal, the editor drives the rest of your UI for free:

function EditorWithStatus() {
  const editor = createEditor({ value: 'hello', language: 'markdown' })

  return (
    <>
      <CodeEditor instance={editor} style="height: 300px" />
      <footer>
        Line {() => editor.cursor().line}, Col {() => editor.cursor().col}
        {' · '}
        {() => editor.lineCount()} lines
        {() => (editor.selection().text ? ` · ${editor.selection().text.length} selected` : '')}
      </footer>
    </>
  )
}

Configuration

createEditor(config?) accepts the full options set below. Every field is optional; the defaults below are the actual runtime defaults.

const editor = createEditor({
  value: '', // initial content                       (default: '')
  language: 'plain', // syntax highlighting language          (default: 'plain')
  theme: 'light', // 'light' | 'dark' | Extension          (default: 'light')
  lineNumbers: true, // show the line-number gutter            (default: true)
  readOnly: false, // read-only mode                         (default: false)
  foldGutter: true, // code folding gutter                    (default: true)
  bracketMatching: true, // bracket matching + auto-close          (default: true)
  autocomplete: true, // completion popups                      (default: true)
  search: true, // find & replace keymap (Cmd/Ctrl+F)     (default: true)
  lint: false, // lint gutter + lint-nav keymap          (default: false)
  highlightIndentGuides: true, // indent guide lines                     (default: true)
  vim: false, // vim keybinding mode (optional dep)     (default: false)
  emacs: false, // emacs keybinding mode (optional dep)   (default: false)
  tabSize: 2, // indent width in spaces                 (default: 2)
  lineWrapping: false, // soft-wrap long lines                   (default: false)
  placeholder: 'Type here...', // placeholder shown when empty           (default: none)
  minimap: false, // canvas code-overview sidebar           (default: false)
  extensions: [], // extra raw CodeMirror extensions        (default: [])
  onChange: (value) => {}, // called on every content change         (default: none)
  onError: (err) => {}, // mount failure → here, not unhandled    (default: none)
})

A few options that behave non-obviously:

  • tabSize sets both the indent width and the spacing of the indent guides. The editor indents with spaces (indentUnit is ' '.repeat(tabSize)).

  • lint: true adds the lint gutter affordance and the lint-navigation keymap. Diagnostic underlines from setDiagnostics render regardless of this flag — lint only controls the gutter + keymap.

  • extensions is an escape hatch: any array of raw CodeMirror 6 Extensions is appended to the built-in set, so you can drop in any third-party CM extension.

  • onError receives a mount failure (a throwing extension or a failed language-grammar import) instead of leaving it as an unhandled promise rejection. Disposing the editor while it is still mounting — a fast navigate-away during the async grammar load — is leak-safe (the in-flight mount is aborted).

Languages

20 language identifiers are supported. 19 ship a real grammar (lazy-loaded as an optional dependency); only plain resolves to an empty extension (plain-text editing, no syntax highlighting). 17 come from the modern @codemirror/lang-* packages; ruby and shell come from @codemirror/legacy-modes (CodeMirror-5-era StreamLanguage grammars).

javascript  typescript  jsx   tsx    html   css    json   markdown
python      rust        sql   xml    yaml   cpp    java   go      php
ruby        shell       plain*                       (* plain-text — no grammar)

Grammars are imported on demand — zero cost until used. Switching language at runtime lazy-loads the new grammar and hot-swaps it into the live view:

editor.language.set('python') // loads @codemirror/lang-python, then swaps

You rarely call the language API directly (createEditor({ language }) and editor.language.set() handle loading), but the lower-level helpers are exported:

import { getAvailableLanguages, loadLanguage } from '@pyreon/code'

getAvailableLanguages() // EditorLanguage[] — all 20 identifiers

// Preload a grammar before mounting (e.g. to avoid a flash):
const ext = await loadLanguage('typescript') // returns the CodeMirror Extension

Themes

Two built-in themes ship as exported extensions, plus a resolver:

import { lightTheme, darkTheme, resolveTheme } from '@pyreon/code'

// Switch dynamically
editor.theme.set('dark') // 'light' | 'dark' string shorthands
editor.theme.set('light')

// Or pass any CodeMirror theme Extension as a custom theme
import { EditorView } from '@codemirror/view'
const myTheme = EditorView.theme({ '&': { backgroundColor: '#0d1117' } })
editor.theme.set(myTheme)

EditorTheme is 'light' | 'dark' | Extension. resolveTheme(theme) maps the string shorthands to the built-in extensions and passes a custom Extension through unchanged — useful if you're assembling a raw CodeMirror state outside createEditor.

Actions

The instance exposes imperative editor commands. Most operate on the live EditorView, so they no-op before the editor has mounted:

editor.focus() // focus the editor
editor.insert('// comment') // insert text at the cursor
editor.replaceSelection('replacement') // replace the current selection
editor.select(0, 10) // select a character range
editor.selectAll() // select everything
editor.goToLine(42) // move cursor to a line + scroll + focus
editor.undo() // undo
editor.redo() // redo
editor.foldAll() // fold every foldable block
editor.unfoldAll() // unfold everything
editor.scrollTo(120) // scroll a character offset into view (centered)

Diagnostics (Lint Integration)

Push diagnostics from external tools (TypeScript, ESLint, your own parser) as character-offset ranges. They render as squiggly underlines via @codemirror/lint:

editor.setDiagnostics([
  { from: 0, to: 5, severity: 'error', message: 'Unexpected token', source: 'typescript' },
  { from: 20, to: 30, severity: 'warning', message: 'Unused variable', source: 'eslint' },
])

editor.clearDiagnostics()

A Diagnostic is { from, to, severity, message, source? }. Severities are 'error' | 'warning' | 'info' | 'hint' ('hint' is rendered as info by CodeMirror). The underlines render whether or not lint: true is set; enabling lint adds the gutter markers and the lint-navigation keymap.

Line Highlights

Highlight whole lines by CSS class — error lines, breakpoints, the current execution line in a debugger:

editor.highlightLine(5, 'error-line') // add a highlight to line 5
editor.highlightLine(10, 'current-line') // a different style
editor.clearLineHighlights() // remove all line highlights

Highlights are stored by line number and re-applied to the live view as the document changes, so they survive edits. Define the classes (.error-line { background: #fee }, etc.) in your own CSS.

Gutter Markers

Place icons in the gutter — breakpoints, error indicators, anything text or emoji:

editor.setGutterMarker(5, { text: '🔴', title: 'Breakpoint' })
editor.setGutterMarker(12, { text: '⚠️', title: 'Warning', class: 'warning-marker' })
editor.clearGutterMarkers()

A GutterMarker is { text?, title?, class? }text is the marker content, title is the hover tooltip, class is an optional CSS class on the marker element.

Custom Keybindings

Register an editor shortcut. The handler runs on the key combo:

editor.addKeybinding('Ctrl-Shift-L', () => {
  console.log('Custom shortcut!')
  return true // (any return is treated as handled)
})

The key string uses CodeMirror's key binding syntax (Mod- for Cmd/Ctrl, Shift-, etc.).

Text Queries

Read content without going through the signal:

editor.getLine(5) // text of line 5 (1-based, clamped to bounds)
editor.getWordAtCursor() // the word under the cursor ('' if none)

Minimap

A canvas-based code overview — a scaled-down render of the entire document on the right edge, with a viewport indicator and click-to-scroll. Enable it via config:

const editor = createEditor({
  value: longCode,
  minimap: true, // adds the canvas overview sidebar
})

For advanced setups (e.g. building a raw CodeMirror state yourself, or composing into config.extensions), the extension is exported standalone:

import { minimapExtension } from '@pyreon/code'

const editor = createEditor({
  value: longCode,
  extensions: [minimapExtension()], // equivalent to minimap: true
})

The minimap reads the editor's dark/light state to pick its background, renders a glyph-per-character overview, draws a viewport rectangle at your scroll position, and jumps to a section on click.

Two-Way Signal Binding

The single most error-prone pattern with any code editor is keeping it in sync with external state (a form field, a store, a config object) without an echo loop — where an external write triggers an editor write that triggers the external write again. bindEditorToSignal collapses the hand-rolled flag-pair boilerplate into one call:

import { createEditor, bindEditorToSignal } from '@pyreon/code'
import { signal } from '@pyreon/reactivity'

interface Config {
  host: string
  port: number
}

const config = signal<Config>({ host: 'localhost', port: 3000 })

const editor = createEditor({
  value: JSON.stringify(config(), null, 2),
  language: 'json',
})

const binding = bindEditorToSignal({
  editor,
  signal: config,
  serialize: (val) => JSON.stringify(val, null, 2),
  parse: (text) => {
    try {
      return JSON.parse(text)
    } catch {
      return null // parse failure → external state untouched
    }
  },
  onParseError: (err) => console.warn('Invalid JSON:', err.message),
})

// later, on unmount:
binding.dispose()

bindEditorToSignal<T>(options) returns an EditorBinding with a single dispose() method. The signal may be a real Pyreon Signal<T> or any SignalLike<T> — anything that exposes a () => T reader plus a set(value) writer (e.g. a @pyreon/flow accessor or a custom store), with an optional peek().

What it guarantees:

  1. No echo loops. Two internal flags break the format-on-input race at the external↔editor boundary (the editor's own CM↔signal sync is already loop-safe).

  2. Parse errors don't crash the binding. A parse that returns null (or throws) leaves the external state at its last valid value and routes the error to onParseError. The user keeps typing.

  3. Both directions are disposable. dispose() tears down both effects.

What it does NOT do: it doesn't debounce (every successful parse calls signal.set), doesn't preserve cursor across a forced re-serialization, and doesn't register itself with the component lifecycle — you call dispose() yourself.

Auto-Cleanup Binding Hook

useEditorSignal is the lifecycle-aware wrapper around bindEditorToSignal — call it inside a component body and it disposes the binding automatically on unmount, so you never write dispose() by hand:

import { createEditor, CodeEditor, useEditorSignal } from '@pyreon/code'
import { signal } from '@pyreon/reactivity'

function CodeField() {
  const code = signal('console.log("hello")')
  const editor = createEditor({ value: code(), language: 'javascript' })

  // identity serialize/parse — bind raw text to a string signal
  useEditorSignal({
    editor,
    signal: code,
    serialize: (val) => val,
    parse: (text) => text,
  })

  return <CodeEditor instance={editor} style="height: 240px" />
}

useEditorSignal takes the same BindEditorToSignalOptions<T> and returns void (the binding is owned by the component). Prefer it over manual bindEditorToSignal + dispose() whenever you're inside a component.

Diff Editor

<DiffEditor> renders a side-by-side (or inline) diff using @codemirror/merge, with collapsed unchanged regions:

import { DiffEditor } from '@pyreon/code'

<DiffEditor
  original={`const x = 1\nconst y = 2`}
  modified={`const x = 1\nconst y = 3\nconst z = 4`}
  language="typescript"
  theme="dark"
  style="height: 400px"
/>

// Inline diff instead of side-by-side
<DiffEditor original={oldText} modified={newText} inline />

original and modified accept a plain string or a Signal<string> — pass a signal and the corresponding pane updates reactively when the signal changes:

const left = signal('// before')
const right = signal('// after')

<DiffEditor original={left} modified={right} language="javascript" />

right.set('// after (edited)') // the right pane re-renders

DiffEditorProps: original, modified (required), plus optional language, theme, inline (default false), readOnly (default true), style, class. It mounts two CodeMirror instances and tears them down on unmount.

Tabbed Editor

<TabbedEditor> renders a headless tab bar above a single editor, with content cached per tab. It takes a TabbedEditorInstance via its instance prop:

import { TabbedEditor } from '@pyreon/code'

<TabbedEditor instance={tabbedInstance} style="height: 500px" />

For reference, the TabbedEditorInstance shape (returned by the not-yet-exported createTabbedEditor) is:

instance.editor // the underlying EditorInstance
instance.tabs() // Signal<Tab[]> — all open tabs
instance.activeTab() // Computed<Tab | null> — the current tab
instance.activeTabId() // Signal<string>

instance.openTab({ name: 'utils.ts', language: 'typescript', value: '' })
instance.closeTab('style.css')
instance.switchTab('index.ts')
instance.renameTab('index.ts', 'main.ts')
instance.setModified('index.ts', true) // show the modified dot
instance.moveTab(0, 2) // reorder
instance.getTab('index.ts') // Tab | undefined
instance.closeAll() // close every tab
instance.closeOthers('index.ts') // close all but one
instance.dispose() // tear down

A Tab is { id?, name, language?, value, modified?, closable? }id defaults to name, closable defaults to true. The tab bar shows a dot on modified tabs and a × on closable ones; switching tabs caches the current content and restores the target's.

Vim / Emacs Mode

Vim and Emacs key modes are supported but their packages are not bundled — install the one you need and flip the config flag:

bun add @replit/codemirror-vim
bun add @replit/codemirror-emacs
const editor = createEditor({
  value: 'hello world',
  vim: true, // or emacs: true
})

If the corresponding package isn't installed, the flag is silently ignored (the editor still works with default keys).

Accessing CodeMirror Directly

For anything the instance API doesn't cover, reach for the raw EditorView:

const view = editor.view() // EditorView | null (null before mount)

if (view) {
  view.dispatch({
    /* any raw CodeMirror transaction */
  })
}

editor.view is a reactive signal, so you can also effect(() => { const v = editor.view(); if (v) … }) to run setup the moment the view exists.

Cleanup

createEditor instances expose dispose() to destroy the underlying view and detach listeners:

editor.dispose() // destroys the EditorView, resets editor.view() to null

When you mount through <CodeEditor> / <TabbedEditor> / <DiffEditor>, the components handle mounting and teardown on unmount for you — you only call dispose() manually if you created an instance you're managing outside the component tree.

Common Patterns

Read-only viewer

const viewer = createEditor({
  value: sourceCode,
  language: 'typescript',
  readOnly: true,
  lineNumbers: true,
  theme: 'dark',
})

<CodeEditor instance={viewer} style="height: 300px" />

Live-validated JSON editor

function JsonEditor() {
  const data = signal({ name: 'Alice', age: 30 })
  const error = signal<string | null>(null)

  const editor = createEditor({
    value: JSON.stringify(data(), null, 2),
    language: 'json',
  })

  useEditorSignal({
    editor,
    signal: data,
    serialize: (v) => JSON.stringify(v, null, 2),
    parse: (text) => {
      try {
        return JSON.parse(text)
      } catch {
        return null
      }
    },
    onParseError: (e) => error.set(e.message),
  })

  return (
    <>
      <CodeEditor instance={editor} style="height: 320px" />
      {() => (error() ? <p class="error">{error()}</p> : null)}
    </>
  )
}

Debugger gutter — breakpoints + current line

function attachBreakpoints(editor: EditorInstance) {
  let line = 1

  editor.addKeybinding('F9', () => {
    editor.setGutterMarker(editor.cursor().line, { text: '🔴', title: 'Breakpoint' })
    return true
  })

  // highlight the line currently executing
  editor.highlightLine(line, 'current-line')
}

API Reference

createEditor(config?)

Returns an EditorInstance. See Configuration for the full config field list.

Reactive state

MemberTypeDescription
valueSignal<string>Editor content — read with value(), write value.set()
languageSignal<EditorLanguage>Current language; setting it lazy-loads + swaps the grammar
themeSignal<EditorTheme>'light' | 'dark' | Extension
readOnlySignal<boolean>Read-only state
focusedSignal<boolean>Focus state (the editor writes it on focus/blur)
cursorComputed<&#123; line, col &#125;>Cursor position, 1-based (read-only)
selectionComputed<&#123; from, to, text &#125;>Current selection (read-only)
lineCountComputed<number>Number of lines (read-only)
viewSignal<EditorView | null>Underlying CodeMirror view; null until mounted
configEditorConfigThe config object the instance was created with

Methods

MethodReturnsDescription
focus()voidFocus the editor
insert(text)voidInsert at cursor (needs a mounted view — see warning)
replaceSelection(text)voidReplace the current selection (needs a mounted view)
select(from, to)voidSelect a character range
selectAll()voidSelect the whole document
goToLine(line)voidMove cursor to a line, scroll into view, and focus
undo() / redo()voidHistory navigation
foldAll() / unfoldAll()voidFold / unfold every foldable block
scrollTo(pos)voidScroll a character offset into view (centered)
setDiagnostics(diagnostics)voidRender lint diagnostics (array of Diagnostic)
clearDiagnostics()voidRemove all diagnostics
highlightLine(line, className)voidAdd a CSS-class line highlight
clearLineHighlights()voidRemove all line highlights
setGutterMarker(line, marker)voidPlace a gutter marker (GutterMarker)
clearGutterMarkers()voidRemove all gutter markers
addKeybinding(key, handler)voidRegister a custom key binding (CodeMirror key syntax)
getLine(line)stringText of a line (1-based, clamped); '' before mount
getWordAtCursor()stringWord under the cursor; '' if none / before mount
dispose()voidDestroy the view and detach listeners

EditorConfig

FieldTypeDefaultDescription
valuestring''Initial content
languageEditorLanguage'plain'Syntax highlighting language
themeEditorTheme'light''light' | 'dark' | Extension
lineNumbersbooleantrueShow line-number gutter
readOnlybooleanfalseRead-only mode
foldGutterbooleantrueCode folding gutter
bracketMatchingbooleantrueBracket matching + auto-close
autocompletebooleantrueCompletion popups
searchbooleantrueFind & replace keymap
lintbooleanfalseLint gutter + lint-navigation keymap
highlightIndentGuidesbooleantrueIndent guide lines
vimbooleanfalseVim mode (needs @replit/codemirror-vim)
emacsbooleanfalseEmacs mode (needs @replit/codemirror-emacs)
tabSizenumber2Indent width in spaces
lineWrappingbooleanfalseSoft-wrap long lines
placeholderstringPlaceholder shown when empty
minimapbooleanfalseCanvas code-overview sidebar
extensionsExtension[][]Extra raw CodeMirror extensions
onChange(value: string) => voidCalled on every content change

bindEditorToSignal(options)

Two-way binding between an EditorInstance and a SignalLike<T>. Returns an EditorBinding.

OptionTypeDescription
editorEditorInstanceThe editor to bind
signalSignalLike<T>External state — a Signal<T> or any () => T + set(v) pair
serialize(value: T) => stringProject external value into editor text (must be deterministic)
parse(text: string) => T | nullParse editor text back; return null (or throw) on failure
onParseError?(error: Error) => voidOptional handler for parse failures

EditorBinding = { dispose(): void }.

useEditorSignal(options)

Same BindEditorToSignalOptions<T> as above; returns void. Call inside a component — disposes the binding automatically on unmount.

loadLanguage(lang) / getAvailableLanguages()

FunctionReturnsDescription
loadLanguage(lang)Promise<Extension>Lazy-load (+ cache) a grammar; [] if the optional pkg is absent
getAvailableLanguages()EditorLanguage[]All 20 supported language identifiers

minimapExtension()

Returns a CodeMirror Extension — the canvas code-overview minimap. Equivalent to createEditor({ minimap: true }); usable in config.extensions or a hand-built CodeMirror state.

Themes

ExportTypeDescription
lightThemeExtensionBuilt-in light theme
darkThemeExtensionBuilt-in dark theme (VS Code inspired)
resolveTheme(theme)(EditorTheme) => ExtensionMap 'light'/'dark' to a theme, pass an Extension through

Components

ComponentPropsDescription
<CodeEditor>instance: EditorInstance, style?, class?Single-file editor
<DiffEditor>original, modified (string | Signal<string>), language?, theme?, inline?, readOnly?, style?, class?Side-by-side / inline diff
<TabbedEditor>instance: TabbedEditorInstance, style?, class?Tab bar over one editor (see note)

Exported types

EditorConfig, EditorInstance, EditorLanguage, EditorTheme, GutterMarker, CodeEditorProps, DiffEditorProps, Tab, TabbedEditorConfig, TabbedEditorInstance, TabbedEditorProps, plus BindEditorToSignalOptions, EditorBinding, and SignalLike from the binding module.

Bundle Size

@pyreon/code is built on CodeMirror 6 instead of Monaco. Language grammars are optionalDependencies loaded on demand by loadLanguage, so a JSON-only editor never downloads the Rust, C++, or Markdown grammar; Vim and Emacs key modes are optional and not bundled at all.

Measured with esbuild (ESM, minify, tree-shake, code-split) + gzip -9 — reproduce with bun run --filter=@pyreon/code bench:

Bundleminifiedgzippednotes
@pyreon/code core editor~416 KB~138 KBCM6 core + wrapper; framework runtime external, no grammar
— + one language grammar~111 KB~41 KBstreams on first use; reuses the loaded core
@pyreon/code full API~444 KB~147 KB+ diff + tabs + minimap + binding
@uiw/react-codemirror~396 KB~129 KBfair peer — wraps the same CM6 (React external)
monaco-editor ESM core~3.6 MB~940 KBconservative lower bound (CSS, fonts, web workers excluded)

The two CM6 wrappers land within ~7% of each other — they wrap the same engine, so the delta is wrapper + which-extensions-each-bundles, not the editor. Against Monaco, @pyreon/code's core is ~7x smaller gzipped, and Monaco's real footprint is larger still once its CSS and web-worker bundles are counted.

This is a bundle-size measurement, not a runtime latency benchmark — the mount / doc-swap / reactive-binding timing comparison is a separate axis (it needs real-browser layout).

Code Editor