Imported from AnonymousNomad/aide-sovereign-workbench (
skills/packs/aide-arch-editor/SKILL.md). Install upstream withnpx skills add AnonymousNomad/aide-sovereign-workbench --skill aide-arch-editor. Copyright stays with the author.
name: aide-arch-editor description: Phase 3 SOP for the AIDE offline-first IDE rebuild — the Monaco editor core: locally bundled monaco-editor with Vite worker wiring (NO CDN), model/view separation (URI-keyed models), open/save round-trip via daemon contract, native undo/redo, find/replace + workspace search, large-file handling (Monaco virtualization replaces the custom windowing), dirty state, split editor groups, view-state restore, and LSP wiring points (didOpen/didChange versioning, markers). Use whenever wiring editor open/save/tabs/find/split/LSP, debugging "editor blank", "undo lost", "model mismatch", or worker loading failures. Research-grounded (github.com/microsoft/monaco-editor, VS Code engineering docs).
AIDE Architecture — Phase 3: Editor (Monaco)
Doctrine
- Monaco, offline. The exact engine VS Code uses. Bundled locally via npm — NO CDN, no loader from jsdelivr. All workers local. Fonts local.
- Model/View separation (the core Monaco law):
editor.createModel(content, language, uri)creates the MODEL (content + language + edit history, URI-keyed, process-wide).monaco.editor.create(dom, { model })creates a VIEW of that model. One model, many views (split editors). View state is serializable and restorable. - The daemon owns the filesystem, Monaco owns the text. The frontend never writes files directly (renderer rule); save = push model content to the daemon file route.
- Native undo/redo. Monaco has a built-in, correct undo/redo stack per model. The custom undo-stack.mjs is superseded for the editor (its verified cap logic survives conceptually in session/ops hygiene, but Monaco's stack is the source of truth). Never reimplement undo on top of Monaco.
- Verify before claiming: open/save/find/split round-trips verified live in the real browser at 127.0.0.1:4173 before a phase is called done.
What
Phase 3 replaces the hand-rolled editor (textarea + renderEditorText + custom windowing + custom undo) with Monaco:
- Monaco bootstrap: local import, CSS, worker wiring for Vite (see How #1).
- File lifecycle: open (daemon GET /api/file → tooLarge gate → model create or "file too large" fallback path), save (dirty → daemon POST), close (dirty guard, LSP didClose, diagnostics clear — ported verified behavior).
- Tabs + splits: tab bar = open models (uri-keyed); split = second editor VIEW on the same model; view states saved per tab in the session.
- Find/replace: Monaco's built-in find widget (options: case, regex, whole word, selection) + workspace search/replace via daemon contract (the verified /api/search/replace semantics: approved flag, dotfile/node_modules skip, 20k cap, ≤512KiB per file).
- Large files: Monaco renders virtualized lines natively — the custom WINDOW_LINES=40 windowing is deleted. The 1MiB too-large gate stays as the load guard (verified:
{too_large:true, size}→ user prompt to open raw/read-only or skip). - Dirty state + hot-exit: dirty flag per model; save prompts on close; session persists open models + view states + caret (shared zod schema).
- LSP wiring points (protocols in Phase 5): didOpen on model create, didChange debounced with version counter, didSave on save, didClose on close, publishDiagnostics → monaco markers, completions/hover/definition commands bound to Monaco APIs.
- Theme/fonts: dark theme (match current styles), monospace font bundled locally, correct font metrics so cursor/line height are stable.
How
1. Monaco + Vite worker wiring (the classic failure point)
- Install
monaco-editoras a local dep. NOmonaco-editor/esm/vs/loaderCDN usage. - Workers: Monaco needs web workers for its language services (typescript/json/css/html basics). With Vite, import workers explicitly:
import editorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker'; import tsWorker from 'monaco-editor/esm/vs/language/typescript/ts.worker?worker'; self.MonacoEnvironment = { getWorker(_, label) { if (label === 'typescript' || label === 'javascript') return new tsWorker(); return new editorWorker(); } }; - Import the ESM entry + css:
import * as monaco from 'monaco-editor/esm/vs/editor/editor.api'; import 'monaco-editor/min/vs/editor/editor.main.css';(or the esm css path matching the version). - Vite
build.assetsInlineLimit/ worker format: workers are emitted as separate chunks — verify they exist indist/after build and are served LOCALLY (offline rule: no worker from remote origin). - Version pinning: lock the exact monaco version in package.json (upgrades change worker/API surface; deliberate upgrades only).
2. Model registry (the editor's source of truth)
// browser/src/editor/models.ts
const models = new Map<string, monaco.editor.ITextModel>(); // key: workspace-relative path
export function getOrCreateModel(relPath: string, content: string, language: string): ITextModel
export function disposeModel(relPath: string): void
export function dirty(relPath: string): boolean
- Language mapping: extension → languageId (js/ts → typescript, py → python, html/css/json/md/gitignore…). Registered via
monaco.languages.registerif a language is missing. - One model per file, process-wide — split views share it; LSP didOpen fires once per model, not per view.
- URI convention:
inmemory://model/<relPath>or a plainfile://-style scheme — pick ONE and keep it in the contract/session schema (restore must reproduce the same URI).
3. Open/save round-trip
- Open:
api.file.read(relPath)→ envelope → iftooLarge→ show the gate UX (no model created; offer read-only raw view via the same route with raw=true if the daemon supports it, else skip). Else create model, set value, mark clean, notify LSP didOpen. - Save:
model.getValue()→api.file.write(relPath, content)→ on ok mark clean + LSP didSave; on error envelope → keep dirty + toast the code. - Debounce: save on Ctrl+S + on tab close + on window/pagehide flush (port the verified pagehide flush pattern).
- Auto-save option (if the old app had it) stays opt-in.
4. Find/replace
- In-file: Monaco find widget (
findaction), configurefind.addExtraSpaceOnTop, case/regex/whole-word/selection toggles; results scroll the view automatically (Monaco handles it — no manual scrollIntoView hacks). - Workspace:
api.search.replacecontract ported verbatim (approved flag — UI shows a confirm dialog with occurrence count BEFORE sending approved:true; dotfiles/node_modules/target/.git/dist/build skipped; per-file ≤512KiB; 20k cap). Result: per-file replacement counts; then reload the AFFECTED models' content (model.setValue or a dedicated reload for dirty files — NEVER overwrite dirty unsaved buffers; skip files that are dirty with a clear report).
5. Splits, view state, hot-exit
- Split = second
monaco.editor.createon the same model; each view's state viaeditor.saveViewState()/restoreViewState()(scroll, caret, folding — all serializable). - Session (shared zod schema, daemon-persisted): tabs [{relPath, language, viewStates: [per group], dirty: bool}], active tab, split layout (vertical/horizontal, sizes).
- Restore order: create all models → create views → restore view states → THEN paint shell (prevents blank flash — verified lesson).
6. LSP integration points (interface only; Phase 5 implements)
editor/lsp-bridge.tsexposes:onOpen(model, relPath),onChange(model, version),onSave(model),onClose(model),setMarkers(relPath, monaco.MarkerData[]),requestCompletion(position)→CompletionList, hover, definition. Phase 5 implements the bridge against the LSP manager; Phase 3 just defines and stubs it with zero external effects.- Version counter:
model.getVersionId()is the Monaco-native version — use it for didChange version tracking (no custom counters).
Why (research grounding)
- Monaco docs (github.com/microsoft/monaco-editor): models are the state, editors are views, view states serializable — this is why VS Code tabs/splits/restore "just work"; we inherit it.
- VS Code uses Monaco for files of any size via virtualization — the custom windowing was a workaround for a hand-rolled textarea; it is deleted, not ported.
- VS Code engineering docs: renderer owns the editor, privileged host owns fs — our save path (model → contract → daemon) is that split.
- Verified project lessons: the 1MiB too-large gate + pagehide flush + dirty-close confirm + LSP didClose/diagnostics-clear on tab close are proven behavior — ported as contracts, not re-invented.
Dependencies
monaco-editor (pinned), Vite worker support (Phase 0), common/contracts file.ts (Phase 1), api client (Phase 2), LSP bridge stub (Phase 5 fills). Node-side: nothing new — the daemon already has file routes + search/replace.
Known issues / bugs (watch these)
- Worker 404s: if dist/ is missing worker chunks or paths are wrong, editor loads but language features (TS) silently die — verify workers exist in dist and are requested from the same origin. Check the Network tab after
vite build. - Monaco CSS: forgetting editor.main.css import = broken layout/overlap between editor and overlays (LEARN/MAP/EXP/RUN must keep absolute positioning over the editor column — verified Phase 2 rule).
- EOL/CRLF:
model.getValue(monaco.editor.EndOfLinePreference.CRLF)vs LF — files written must preserve the ORIGINAL EOL; detect on read (model.detectIndentation / EOL sniff) and write back the same. CRLF round-trip bugs are silent data corruption. - BOM: strip on read, restore on write if the original had one (UTF-8 BOM); forgetting = diff noise in git.
- model.setValue resets undo history: when reloading a file after workspace replace, ONLY setValue if the model is clean; dirty files must NOT be clobbered (report + keep buffer). setValue also resets markers — re-request diagnostics after.
- Undo across save: Monaco undo survives saves natively; do NOT clear undo stack on save.
- Memory: dispose models on tab close (unused models leak memory — the process-wide registry must be the only holder).
- IME/unicode: Monaco handles IME; but
getValuewith astral chars is fine — never do manual offset math on the string; use Monaco positions/offsets APIs. - Find widget overlap: the find widget is inside the editor DOM; the custom find-bar DOM element from the old app is removed (no dual find UIs).
- Font: non-bundled font = FOUT + wrong metrics (cursor misalignment). Bundle a monospace font (e.g. JetBrains Mono or Fira Code woff2, local file) and set it in the editor options AND the CSS, matching family/size.
- Glob masks (FOUND 2026-08-18, FIXED):
escapeRegExp(pattern).replace(/\*/g,'.*')is WRONG — escape leaves\*in the string, then the star replacement yields\..*(a literal dot + anything) instead of.*. Real symptom:mask=*.jsonmatched NOTHING while exact masks worked (search "accepts a file mask" test was vacuous — it never passed a mask). Correct order: escape regex specials EXCEPT*and?first (/[.+^${}()|[\]\\]/g), then*→.*,?→.(node/src/routes/fs.ts matchMask). Regression test must pass a REAL glob (e.g.*.txtand*.t?).
Phase 3 audit checklist (applied to the existing editor code)
- monaco-editor local; workers emitted to dist/ and served same-origin; zero CDN references anywhere (grep for http(s):// in browser code).
- Custom textarea editor, renderEditorText windowing, custom undo-stack wiring, custom find-bar DOM all REMOVED; Monaco owns editing/find/undo/virtualization.
- Model registry: one model per file; split views share models; dispose on close.
- Round-trip verified live: open → edit → save → reopen shows saved content; CRLF + BOM preserved on a test file.
- Dirty guard: closing a dirty tab prompts; session restore reopens tabs + view states without blank flash.
- Workspace replace reloads clean models, reports/skips dirty ones; approved-flag confirm flow intact.
- LSP bridge stub compiles (Phase 5 implements);
npm run checkgreen.