Phase 3 — Editor Core SOP (battle-tested, repo-verified)
SkillSearchPhase 3 SOP for the AIDE offline IDE, make the code editor actually edit files: MVVM editor contract, file open/save round-trip via daemon routes, tabs, find/replace, workspace search, undo/redo stack, split editor groups, dirty state, hot-exit recovery, large-file handling, and LSP integration (spawn, initialize handshake, didOpen/didChange, completion/hover/definition, shutdown). Use whenever wiring explorer/file loading/save/find/terminal/LSP-DAP or debugging "file won't open/save", "undo lost", "LSP returns nothing", or "search finds nothing" issues.
Use Phase 3 — Editor Core SOP (battle-tested, repo-verified) in Claude, ChatGPT or Ahel Desktop
Free. Sign in, add Phase 3 — Editor Core SOP (battle-tested, repo-verified) and connect your AI. About a minute.
Also: Claude Code · Cursor · Codex
Then ask your AI: use the Phase 3 skill
Details
Instructions available. Your AI can read the instructions. Execution depends on the setup they require.
Account requirements not reviewed. Check the skill instructions before use; Ahel provides instructions and does not run this skill.
No other account needed.
Add Ahel to your AI once: Claude, ChatGPT, Cursor, Claude Code or Codex. Then ask it to use this.
What this skill tells your AI
The instructions your AI receives, as published by anonymousnomad/covert-coder in skills/packs/aide-phase3-editor-core/SKILL.md and read by Ahel’s review.
Goal: the editor behaves like VS Code on a 1-file-per-feature vanilla-JS budget: browse tree → open file → edit → Ctrl+S saves atomically → Ctrl+F finds/replaces → Ctrl+Shift+F searches the workspace → Ctrl+Z/Y undoes/redoes → SPLIT gives a second pane → dirty state survives daemon restart without data loss → LSP gives completion/hover/definition/diagnostics.
All routes, function names, and JSON shapes below were verified against the repo at E:\aide-sovereign-workbench (app.js, index.html, editor/undo-stack.mjs, editor/groups.mjs, daemon/server.mjs, daemon/workspace-manager.mjs, daemon/lsp-manager.mjs, session/store.mjs, scripts/editor-smoke.*, scripts/ui-audit.mjs, package.json). Anything that does NOT exist yet is marked [TODO] with the exact behavior required. Do not invent routes or functions that are not listed here.
Research base (source → principle → applied as)
| Source | Principle | Applied as |
|---|---|---|
VS Code Monaco design doc (microsoft/vscode-wiki [WIP]-Code-Editor-Design-Doc.md) | MVVM: Model holds text + edit history + markers, knows nothing about display; ViewModel converts Model→View (tab stops, positions) and is an EventEmitter; View renders DOM from ViewModel events only; truth lives in JS objects, never in the DOM | UndoStack (editor/undo-stack.mjs) is the Model for a file's text+history; renderEditorText()/refreshLineNumbers() are the View; state.editorStacks Map + currentStack() are the ViewModel bridge. DOM is a projection, re-rendered from stack.text() |
| Monaco view parts | Only render what is visible; dirty-check before DOM writes | refreshLineNumbers() re-renders line numbers only when content changes; [TODO] virtual scrolling for large files (render ~40-line window, not the whole pre) |
| Eclipse Theia widgets (theia-ide.org/docs/widgets + custom_editors) | Widget = id/label/closable + factory (WidgetManager.getOrCreate) + contribution; open handlers claim file URIs by canHandle priority; editor tabs are widgets over a file URI | openFile(name) + renderEditorTabs() + EditorGroups (editor/groups.mjs) mirror widget/tab management; state.editorStacks is the per-URI model registry; ensureEditorModules() lazily registers the window.UndoStack/window.EditorGroups factories |
| LSP 3.17 spec (microsoft.github.io/language-server-protocol) | JSON-RPC 2.0 over stdio with Content-Length framing; lifecycle initialize → initialized → textDocument/didOpen → didChange (versioned) → requests (completion/hover/definition) → server pushes textDocument/publishDiagnostics → shutdown + exit; positions are 0-based {line, character} UTF-16 | LspManager (daemon/lsp-manager.mjs) already implements spawn + framing + pending-map timeouts + diagnostics capture; frontend checkActiveFile()/lspAction() drive it; [TODO] auto didOpen/didChange wiring + real cursor position |
| W3C ContentEditable WD 2025 + contenteditable.lab reality catalog | contenteditable behaves differently per browser/IME: Enter inserts div vs br, composition events reorder, native undo clears, paste keeps formatting; W3C recommends JS owns the data model and renders to the DOM, not vice versa | AIDE keeps its OWN undo stack (UndoStack.apply via diffOperation); [TODO] normalize the pre#code DOM on input (strip br/div → \n), guard IME compositionstart/end, force plain-text paste |
| Monaco virtual rendering / viewport tracker | Line-based virtual rendering: only visible lines exist in the DOM, viewport tracker re-renders on scroll | [TODO] windowed rendering for files over a size threshold; find marks computed from the Model, not the DOM |
Editor core architecture (MVVM contract for AIDE)
The repo already follows Monaco's MVVM shape. Keep these invariants — every fix must respect them:
- Model: one
UndoStackper open file, stored instate.editorStacks(Map, app.js:1). It ownsbase(disk text),ops[](diff ops),position,baseline.stack.text()is the ONLY authoritative content source (renderEditorText()reads it, app.js:63). - No truth in the DOM:
#codeis a projection. The one current violation is theoninputhandler (app.js:1156) which reads$('#code').textContentback as truth. That is acceptable ONLY becausediffOperation(before, after)+stack.applyreconstructs the Model from the diff; if the DOM was corrupted by browser edit quirks (see contenteditable pitfalls below), the model diverges — that is why the handler resets the stack tonew UndoStack(after)on apply failure (app.js:1166). - Events, not direct calls: all UI actions funnel through the same functions the tests call:
openFile,saveFile,undoEditor,redoEditor,openFind,markFind,findNext,replaceCurrent,replaceAll,searchWorkspace,syncDirty,renderEditorTabs,toggleSplitEditor. The headless smoke test (scripts/editor-smoke.html) calls these functions directly, so keep their signatures stable. - View = function of state:
renderEditorText()→refreshLineNumbers()→ re-apply find marks;renderEditorTabs()renders tabs fromstate.openFiles+state.dirtyFiles+state.activeFile. Dirty state is derived (UndoStack.dirty, editor/undo-stack.mjs:70), never stored as a flag. - Session is a snapshot:
saveSession()PUTs{active_file, open_files, buffers}(dirty buffers only) to/api/session;restoreSession()(app.js:246) replaysbuffersas diff ops against the fresh on-disk baseline so dirty files come back dirty and undo history survives restart.
Step-by-step implementation SOP (numbered)
The following numbered steps are ordered by dependency. Steps 1–10 exist and pass tests; steps marked [TODO] are the verified gaps. Re-run the verification gate named in each step after touching that code.
1. File open round-trip (IMPLEMENTED)
- Route:
GET /api/file?path=<rel>(server.mjs:231) →{path, content}. Read is utf8, path must be workspace-relative (WorkspaceManager.resolve()throws on absolute/escaped paths, workspace-manager.mjs:8-13). - Frontend:
openFile(name)(app.js:200) →ensureEditorModules()→ fetch, offline fallback to the in-memorystate.filescorpus → push tostate.openFiles→ createnew UndoStack(content)if absent →state.editorGroups.open(0, name)→renderEditorText()+renderEditorTabs()+saveSession(). - DOM: explorer buttons
[data-file]are wired inloadWorkspaceTree()(app.js:272) viaGET /api/workspace/tree→renderWorkspaceTree()(recursive,node.kind === 'directory'vs file button, indent by depth). - Gate:
node scripts/editor-smoke.mjs(asserts boot + open + content) + manual curl step in "Daemon API contract".
2. Save round-trip (IMPLEMENTED)
- Route:
POST /api/file/write{path, content, approved: true}(server.mjs:235) →{path, bytes}.WorkspaceManager.writerequiresapproved === true, creates parent dirs, writes*.aide-tmp-<pid>with mode 0o600 then atomicrename(workspace-manager.mjs:35-43). Never sendapprovedwithout a user gesture. - Frontend:
saveFile()(app.js:408) sends{path: state.activeFile, content: stack ? stack.text() : $('#code').textContent, approved: true}; on successstack.markSaved()(setsbaseline = position, so undo history survives save but dirty clears), thensyncDirty(). Errors surface inline in the collab log: "Save blocked: … Start the local daemon and open a trusted workspace." - Keyboard: Ctrl/Cmd+S in
bindEditorShortcuts()(app.js:192) — guarded against typing in INPUT/SELECT/TEXTAREA. - Gate: smoke asserts
save: written to workspaceby re-fetching/api/fileand comparing bytes (editor-smoke.html:102-107).
3. Tabs, dirty markers, close (IMPLEMENTED, one gap)
renderEditorTabs()(app.js:219) renders one button perstate.openFileswithdata-editor-tab, dirty*fromstate.dirtyFiles, a×span (data-close-tab), plus+,SPLIT(#split-editor), andSAVE APPROVED FILEbuttons. Tab click →openFile; close → filteropenFiles, fall back toopenFiles.at(-1) || 'README.md'.syncDirty()(app.js:159) recomputesstate.dirtyFilesfromstack.dirtyacross the stacks Map and only re-renders tabs when the set changed — this is the Monaco "dirty-check before DOM write" rule.[TODO] dirty-close guard: closing a tab with unsaved changes currently discards silently. Before filteringopenFiles, ifstate.dirtyFiles.has(file),window.confirm('Close <file>? Unsaved changes will be lost.')and abort on cancel. Add smoke assertionclose: dirty tab prompts(stubwindow.confirmin the harness).
4. Undo/redo (IMPLEMENTED — Model-level, DOM-proof)
editor/undo-stack.mjsis the Model:diffOperation(before, after)produces[{type:'delete',start,length,deleted}, {type:'insert',start,text}];apply()validates integer/non-negative starts and truncates the redo branch (ops.length = position);text()replays ops frombaseand throws on corruption (corruption is caught and the stack is reset, app.js:1163-1167).undoEditor()/redoEditor()(app.js:148-157) guard onstack.canUndo/canRedo, thenrenderEditorText()+syncDirty().- Keyboard: Ctrl+Z undo; Ctrl+Y or Ctrl+Shift+Z redo (app.js:193-194). Command palette entries
editor-undo/editor-redoexist (app.js:1061-1062). - Contenteditable reality: because the browser's own undo is irrelevant (we rebuild DOM on every render), never rely on
document.execCommandor native undo; keep diffingtextContent. [TODO] cursor restore:renderEditorText()andmarkFind()replace DOM content, which resets the caret to 0. Track the caret (line/column fromwindow.getSelection()+ aRange) per file in the Model (Monaco keeps selection in ViewModel state), and restore it after undo/redo/replace. Smoke assertion:undo: caret restored to prior line.[TODO] ops cap:UndoStack.opsgrows unboundedly; cap at ~200 ops by folding old ops intobase(rebuildbase = textAt(position-200)and resetops/position/baselineaccordingly).
5. Find / replace in file (IMPLEMENTED, DOM-rebuild design is intentional)
openFind()(app.js:166) shows#find-bar;markFind(query, keepIndex)(app.js:70) does a case-insensitiveindexOfscan ofstack.text(), storesstate.findState = {query, matches: [utf16 indices], index}, then rebuilds#codeinnerHTML with<mark>spans (escaped viaesc()) +active-matchclass.updateFindCount()rendersn/totalin#find-count.findNext(dir)cyclesindexwith wrap; Enter/Shift+Enter in#find-input(app.js:179-182), Esc closes viacloseFind().- Replace:
replaceCurrent()(app.js:100) splices viadiffOperation+stack.apply(undoable!) then re-renders;replaceAll()(app.js:114) does a whole-text scan and applies diff ops. Both callsyncDirty(). Replace input + REPLACE / REPLACE ALL buttons exist in#find-bar. [TODO] scroll active match into view($('#code').querySelector('mark.active-match')?.scrollIntoView({block:'nearest'})insidefindNext).[TODO] find history: Monaco keeps the last find query per file; restorestate.findState.queryinopenFile.
6. Workspace search (IMPLEMENTED server-side options, UI gaps)
- Route:
GET /api/search?q=<query>®ex=1&icase=1&word=1&mask=<glob>&include-node_modules=1(server.mjs:239-273) →{query, results:[{path, hits:[{line, text}]}], total, regex, caseInsensitive, wholeWord, fileMask}. Server walks the workspace, skips dotfiles and.git/node_modules/target/dist/buildunlessinclude-*, skips files > 512 KiB, caps at 400 files, and slices hit text to 300 chars. Regex mode is opt-in (regex=1) — literal by default withescapeRegExp. - Frontend:
searchWorkspace()(app.js:130) sends onlyq, renders#search-resultswith per-hit<button data-search-file>→openFile(file). Ctrl+Shift+F (app.js:196). [TODO] expose server options in UI: add checkboxes for icase/regex/word and a mask input, pass them as query params. The server already supports them; the UI just never sends them.[TODO] multi-file replace: there is no replace endpoint. AddPOST /api/search/replace{query, replacement, icase?, regex?, mask?, approved:true}that re-runs the same walker, applies replacement per file viaWorkspaceManager.write, and returns{files_changed, occurrences}. Frontend: REPLACE ALL IN WORKSPACE button in#search-results. Gate: curl the route against a temp workspace, assert changed bytes via/api/file.
7. Split editor groups (IMPLEMENTED core, PERSISTENCE gap)
editor/groups.mjs(EditorGroups):open(i, file),split(file),closeTab(file)(auto-closes empty groups),move(file, i),activate(file),nextGroup(),mruOrder(),state()→{groups:[{id, tabs, active}], activeGroup, mru}. Exposed aswindow.EditorGroups; unit tests in editor/test-groups.mjs.- Frontend:
toggleSplitEditor()(app.js:227) flipsstate.splitEditor, reveals#secondary-editor(index.html:118:#secondary-line-numbers+pre#secondary-code), and callsopenSecondaryFile(name)which fetches/api/fileinto the read-onlypre. [TODO] secondary pane is read-only:#secondary-codehas no contenteditable, no UndoStack, no dirty tracking, no save. Two options: (a) promote it to a full editor (ownUndoStack, own input handler, SAVE button routes the secondary path) — the EditorGroups model already supports 2 groups; or (b) declare it a read-only preview and say so in the UI. The journal flagged split groups as incomplete, so (a) is the parity target.[TODO] persist split layout:saveSession()sends onlyactive_file/open_files/buffers. Extend to{..., split_editor: state.splitEditor, groups: state.editorGroups.state()}and rebuild inrestoreSession()(calleditorGroups.split(...)per extra group, re-open tabs per group). SessionStore just spreads input (session/store.mjs:33), so no daemon change needed.
8. Dirty state (IMPLEMENTED — derived, not stored)
UndoStack.dirty=position !== baseline;markSaved()after successful write.syncDirty()derivesstate.dirtyFilesfrom the stacks and persists dirty buffers to the session (buffers: Object.fromEntries(...stack.dirty...)).- Dirty tabs show
*in the tab label.saveFile()clears it only on daemon-confirmed success.
9. Hot-exit recovery after restart (IMPLEMENTED — verified in journal 2026-08-13)
- Writes:
GET /api/session/PUT /api/session(server.mjs:336-337) →SessionStore(session/store.mjs): sanitizes open_files (relative, no.., ≤ 32, ≤ 512 chars), buffers capped at 512 KiB/file and 4 MiB total, atomic tmp+rename write to.aide/session.json. - Restore:
restoreSession()(app.js:246) fetches the session, reopens each file (freshUndoStackfrom disk), replays each saved buffer viadiffOperation(stack.text(), recovered)so the buffer comes back DIRTY with working undo, then logsRecovered N unsaved buffer(s) without writing to disk.It never auto-writes to disk — recovery is a frontend concern. [TODO] pagehide flush: recovery depends onsyncDirty()having run during editing. Addwindow.addEventListener('pagehide', () => saveSession({...current}))(best-effort, swallowed on failure) so a killed daemon or closed tab still persists the last keystroke.- Gate:
node scripts/hot-exit-contract.mjs(part ofnpm test), plus the manual kill-and-restart curl sequence below.
10. Terminal + tasks (IMPLEMENTED)
POST /api/terminal/run{program, args, approved:true}(server.mjs:274-295) →{code, stdout, stderr}; builtins echo/pwd/ls|dir/cat|type run inside the daemon, everything else must be on the allowlist (node, npm, npx, git, py, python, python3, cargo, rustc), 30 s timeout, ≤ 24 args.runTerminalCommand()(app.js:397) tokenizes quoted args and renders into#terminalwith ok/error classes.- Tasks:
GET /api/tasks,POST /api/tasks/run {id},GET /api/tasks/status,POST /api/tasks/stop—runTask()polls every 250 ms up to 150 s and renders output into the terminal panel. [TODO] interactive streams: the terminal is one-shot exec, not a PTY. If long-running/interactive processes are needed, follow Theia/xterm.js pattern (WS stream from the daemon) — this is a Phase-3 stretch, not a blocker for the editor core.
11. Large-file handling (MISSING — [TODO] end to end)
- Problem today:
GET /api/filereads the whole file, the whole text goes into onepre, and every keystroke re-renders all line numbers. A 5 MB file will freeze the renderer. - Server
[TODO]: inGET /api/file, stat first and return{path, content, too_large: true}(or 413) when size > ~1 MiB instead of shipping the whole blob; keepcontentfor normal files. - Frontend
[TODO]windowed rendering (Monaco's line-based virtual rendering): keep the full text in theUndoStackModel, but render only the visible ~40 lines + overscan into#codewith absolute-positioned rows and a scroll container;refreshLineNumbersrenders only the window; find marks are computed from the Model and only the visible marks are inserted into the DOM. Gate: open a generated 2 MB file headlessly, assert DOM node count stays bounded (smoke assertionlarge: windowed DOM). - Note: workspace search already skips > 512 KiB files (server.mjs:265), so search is safe.
LSP integration SOP (spawn → handshake → sync → features → shutdown)
Transport (already built — daemon/lsp-manager.mjs)
- Manifest:
languages/manifest.json→ server{id:'typescript', command:'node_modules/typescript-language-server/lib/cli.mjs', args:['--stdio'], languages:[...]}. LspManager.start(id)spawns the process with piped stdio, buffers stdout, parsesContent-Length-framed JSON-RPC 2.0 (#consume), routes responses to a pending map with a 15 s timeout (request()), storestextDocument/publishDiagnosticsintothis.diagnosticskeyed by uri (diagnosticsList()).- URI bridging: the daemon rewrites
file:///workspace/<rel>placeholders to realfile:///URIs on the way in (rewriteIncoming, server.mjs:109-115) and rewrites diagnostics URIs back out (toPlaceholderUri, server.mjs:363). The frontend strips the prefix inloadDiagnostics()(app.js:388). - Routes:
POST /api/lsp/start {id},POST /api/lsp/request {id, message},POST /api/lsp/notify {id, message},POST /api/lsp/stop {id},GET /api/lsp/status,GET /api/diagnostics.
Correct lifecycle (handshake order matters — never invert)
- Spawn:
startTool('lsp', 'typescript', ...)(app.js:792) →POST /api/lsp/start {id:'typescript'}→{id, status:'starting', languages}. - initialize request:
POST /api/lsp/request {id, message:{method:'initialize', params:{processId:null, rootUri:null, capabilities:{}}}}(app.js:802). The daemon addsid/jsonrpcand rewrites rootUri. The server replies withresult.capabilities— store these (this is the capability negotiation; only call features the server advertised). - initialized notification:
POST /api/lsp/notify {id, message:{method:'initialized', params:{}}}(app.js:803). The server is now live. - didOpen per file (currently only manual, app.js:823):
POST /api/lsp/notify {id, message:{method:'textDocument/didOpen', params:{textDocument:{uri:'file:///workspace/<rel>', languageId:'typescript', version:1, text:<full content>}}}}. LSP is push-based: the server never reads disk — you must send the whole file. - didChange on every edit
[TODO]: sendtextDocument/didChangewithcontentChanges:[{text: <full current text>}]and an incrementingversion(the server advertises sync kind; full-text sync is the simplest and safest for a small editor). Debounce ~250 ms after the lastinputevent; version must never go backwards. Wire it into the#codeoninputhandler (app.js:1156) next tosyncDirty(). - Features at the real cursor
[TODO]: todaylspAction()hardcodes{line:0, character:0}andcheckActiveFile()sends completion at 0,0. Compute position from the caret (line = count of\nbefore the caret offset, character = UTF-16 offset on that line — LSP is 0-based). Wiredata-lsp-actionbuttons (HOVER/DEFINITION/RENAME/FORMAT exist in index.html:77) to the cursor position. - Diagnostics: the server pushes
textDocument/publishDiagnostics; the daemon stores them; the frontend pollsGET /api/diagnosticsevery 3 s (setInterval(loadDiagnostics, 3000), app.js:1126) and renders#problems-listwith click-to-open (uri →openFile+ line).[TODO]clear diagnostics for a file on didClose. - Shutdown:
POST /api/lsp/stop {id}kills the process.[TODO]sendshutdownrequest +exitnotification before killing (protocol-correct), and stop the LSP on tab close of the last open document of its language + on apppagehide.
LSP curl verification (headless, no browser)
# start server, expect {id,status:'starting'}
curl.exe -s -X POST http://127.0.0.1:4777/api/lsp/start -H "Content-Type: application/json" -d '{"id":"typescript"}'
# initialize -> expect result.capabilities back (server must respond within 15s)
curl.exe -s -X POST http://127.0.0.1:4777/api/lsp/request -H "Content-Type: application/json" -d '{"id":"typescript","message":{"method":"initialize","params":{"processId":null,"rootUri":null,"capabilities":{}}}}'
# initialized notification
curl.exe -s -X POST http://127.0.0.1:4777/api/lsp/notify -H "Content-Type: application/json" -d '{"id":"typescript","message":{"method":"initialized","params":{}}}'
# didOpen a real file
curl.exe -s -X POST http://127.0.0.1:4777/api/lsp/notify -H "Content-Type: application/json" -d '{"id":"typescript","message":{"method":"textDocument/didOpen","params":{"textDocument":{"uri":"file:///workspace/app.js","languageId":"javascript","version":1,"text":"const x: number = 1;"}}}}'
# completion at (0,7)
curl.exe -s -X POST http://127.0.0.1:4777/api/lsp/request -H "Content-Type: application/json" -d '{"id":"typescript","message":{"method":"textDocument/completion","params":{"textDocument":{"uri":"file:///workspace/app.js"},"position":{"line":0,"character":7}}}}'
# diagnostics should list the file after a moment
curl.exe -s http://127.0.0.1:4777/api/diagnostics
# status shows running; stop
curl.exe -s -X POST http://127.0.0.1:4777/api/lsp/stop -H "Content-Type: application/json" -d '{"id":"typescript"}'
Exact daemon API contract (verify with curl)
Daemon: node scripts/start.mjs (or node daemon/server.mjs), listens on http://127.0.0.1:4777, CORS origin http://127.0.0.1:4173. All JSON. Writes require approved: true.
Shortened here. Read the whole file on GitHub.
Signals
- GitHub stars
- 43
- Forks
- 14
- Last commit
- Oct 2026
Advanced
- Item type
- skill
- Key
aide-phase3-editor-core- Source
- github.com/anonymousnomad/covert-coder
github.com/anonymousnomad/covert-coder
Related picks
Skill · thedaviddias
The pick for JavaScriptmodern-javascript-patterns
Skill · wshobson
The pick for JavaScriptsetup-ts-deep-modules
Skill · mattpocock
The pick for TypeScripttypescript-pro
Skill · jeffallan
The pick for TypeScriptpython-performance-optimization
Skill · wshobson
The pick for Pythonpython-pro
Skill · jeffallan
The pick for Python