This repository has been archived on 2026-05-05. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
el-ide/ide/index.html
T
Will Anderson 1172ab6351 feat: el-ide — native IDE for engram-lang, LSP, type graph, plugin ecosystem
Axum HTTP server (port 7771) serving a single-page IDE with CodeMirror 6
syntax highlighting for engram-lang, a force-directed type graph visualizer,
LSP (completions, hover, diagnostics), SSE-streamed build/run output, a
plugin host with five first-party plugins, and a reasoning panel that proxies
to engram-server. 28 tests across three crates, zero warnings.
2026-04-27 19:12:42 -05:00

1467 lines
48 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>el-ide — Engram Language IDE</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=DM+Mono:ital,wght@0,300;0,400;0,500;1,400&family=Syne:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<style>
:root {
--bg: #080b0f;
--bg2: #0d1117;
--bg3: #111820;
--bg4: #161e28;
--border: rgba(255,255,255,0.06);
--border2: rgba(255,255,255,0.12);
--text: #e8edf3;
--text2: #7a8a9a;
--text3: #4a5a6a;
--accent: #38bdf8;
--accent-dim: rgba(56,189,248,0.12);
--accent-glow: rgba(56,189,248,0.4);
--purple: #a78bfa;
--green: #4ade80;
--orange: #fb923c;
--red: #f87171;
--yellow: #fbbf24;
--radius: 4px;
--file-w: 220px;
--graph-w: 280px;
--bottom-h: 200px;
--header-h: 44px;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
background: var(--bg);
color: var(--text);
font-family: 'Syne', sans-serif;
font-size: 13px;
height: 100vh;
overflow: hidden;
display: flex;
flex-direction: column;
}
/* ── HEADER ─────────────────────────────────────────────────────────────────── */
#header {
height: var(--header-h);
min-height: var(--header-h);
background: var(--bg2);
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
padding: 0 14px;
gap: 16px;
z-index: 100;
flex-shrink: 0;
}
.logo {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.logo-mark {
font-family: 'Syne', sans-serif;
font-weight: 800;
font-size: 15px;
letter-spacing: -0.03em;
color: var(--text);
}
.logo-mark span { color: var(--accent); }
.logo-dot {
width: 6px; height: 6px;
border-radius: 50%;
background: var(--accent);
box-shadow: 0 0 10px var(--accent-glow);
animation: pulse-dot 2.5s ease-in-out infinite;
}
@keyframes pulse-dot {
0%,100% { box-shadow: 0 0 6px var(--accent-glow); }
50% { box-shadow: 0 0 18px var(--accent-glow), 0 0 32px rgba(56,189,248,0.15); }
}
.header-sep { width: 1px; height: 22px; background: var(--border2); }
.project-name {
font-family: 'DM Mono', monospace;
font-size: 12px;
color: var(--text2);
flex-shrink: 0;
}
.project-name span { color: var(--text); }
.header-spacer { flex: 1; }
.target-select {
background: var(--bg3);
border: 1px solid var(--border2);
border-radius: var(--radius);
color: var(--text);
font-family: 'DM Mono', monospace;
font-size: 11px;
padding: 4px 8px;
cursor: pointer;
outline: none;
}
.target-select:focus { border-color: var(--accent); }
.btn {
background: var(--bg3);
border: 1px solid var(--border2);
border-radius: var(--radius);
color: var(--text);
font-family: 'Syne', sans-serif;
font-size: 11px;
font-weight: 600;
padding: 5px 12px;
cursor: pointer;
letter-spacing: 0.02em;
transition: all 0.15s;
white-space: nowrap;
}
.btn:hover { background: var(--bg4); border-color: var(--accent); color: var(--accent); }
.btn-primary {
background: var(--accent-dim);
border-color: var(--accent);
color: var(--accent);
}
.btn-primary:hover {
background: rgba(56,189,248,0.2);
box-shadow: 0 0 12px var(--accent-glow);
}
.btn-run {
background: rgba(74,222,128,0.08);
border-color: rgba(74,222,128,0.4);
color: var(--green);
}
.btn-run:hover { background: rgba(74,222,128,0.15); }
/* ── BODY ───────────────────────────────────────────────────────────────────── */
#body {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
#main-row {
flex: 1;
display: flex;
overflow: hidden;
}
/* ── FILE TREE ──────────────────────────────────────────────────────────────── */
#file-tree {
width: var(--file-w);
min-width: var(--file-w);
background: var(--bg2);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
overflow: hidden;
}
.panel-header {
height: 32px;
min-height: 32px;
display: flex;
align-items: center;
padding: 0 12px;
font-size: 10px;
font-weight: 600;
letter-spacing: 0.08em;
color: var(--text3);
text-transform: uppercase;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
#file-list {
flex: 1;
overflow-y: auto;
padding: 6px 0;
}
.file-item {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 12px 4px 12px;
cursor: pointer;
font-family: 'DM Mono', monospace;
font-size: 11.5px;
color: var(--text2);
transition: background 0.1s;
user-select: none;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.file-item:hover { background: var(--bg3); color: var(--text); }
.file-item.active { color: var(--accent); background: var(--accent-dim); }
.file-item .icon {
font-size: 10px;
flex-shrink: 0;
opacity: 0.7;
}
.file-item.dir { color: var(--text2); font-weight: 500; }
.file-item.dir .icon { color: var(--accent); opacity: 1; }
.file-item .indent { display: inline-block; }
/* ── EDITOR ─────────────────────────────────────────────────────────────────── */
#editor-col {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
background: var(--bg);
position: relative;
}
#editor-tabs {
height: 32px;
min-height: 32px;
display: flex;
align-items: stretch;
background: var(--bg2);
border-bottom: 1px solid var(--border);
overflow-x: auto;
flex-shrink: 0;
}
.editor-tab {
display: flex;
align-items: center;
gap: 6px;
padding: 0 14px;
font-family: 'DM Mono', monospace;
font-size: 11.5px;
color: var(--text2);
cursor: pointer;
border-right: 1px solid var(--border);
white-space: nowrap;
transition: background 0.1s;
}
.editor-tab.active {
background: var(--bg);
color: var(--text);
border-bottom: 2px solid var(--accent);
}
.editor-tab .dot {
width: 5px; height: 5px;
border-radius: 50%;
background: var(--orange);
display: none;
}
.editor-tab.unsaved .dot { display: block; }
#editor-wrapper {
flex: 1;
overflow: hidden;
position: relative;
}
#cm-editor {
height: 100%;
font-family: 'DM Mono', monospace;
font-size: 13px;
}
.cm-editor {
height: 100%;
background: var(--bg) !important;
}
.cm-editor.cm-focused { outline: none !important; }
.cm-content { color: var(--text); caret-color: var(--accent); }
.cm-gutters {
background: var(--bg2) !important;
border-right: 1px solid var(--border) !important;
color: var(--text3) !important;
}
.cm-activeLineGutter { background: var(--bg3) !important; }
.cm-activeLine { background: rgba(56,189,248,0.04) !important; }
.cm-selectionBackground { background: rgba(56,189,248,0.15) !important; }
.cm-matchingBracket {
background: rgba(56,189,248,0.12) !important;
border-radius: 2px;
}
/* Syntax tokens */
.tok-keyword { color: var(--accent); }
.tok-keyword2 { color: var(--accent); font-style: italic; } /* activate, sealed */
.tok-type { color: var(--purple); }
.tok-string { color: var(--green); }
.tok-number { color: var(--orange); }
.tok-comment { color: var(--text3); font-style: italic; }
.tok-operator { color: var(--text2); }
.tok-punctuation { color: var(--text3); }
.tok-builtin { color: var(--purple); opacity: 0.8; }
/* LSP squiggles */
.cm-lsp-error {
text-decoration: underline wavy var(--red);
text-decoration-skip-ink: none;
}
/* ── TYPE GRAPH PANEL ───────────────────────────────────────────────────────── */
#type-graph {
width: var(--graph-w);
min-width: var(--graph-w);
background: var(--bg2);
border-left: 1px solid var(--border);
display: flex;
flex-direction: column;
overflow: hidden;
}
#graph-canvas {
flex: 1;
width: 100%;
cursor: grab;
}
#graph-canvas:active { cursor: grabbing; }
#graph-legend {
padding: 8px 12px;
border-top: 1px solid var(--border);
display: flex;
flex-wrap: wrap;
gap: 8px;
flex-shrink: 0;
}
.legend-item {
display: flex;
align-items: center;
gap: 4px;
font-size: 10px;
color: var(--text3);
}
.legend-dot {
width: 8px; height: 8px;
border-radius: 50%;
}
/* ── BOTTOM PANEL ───────────────────────────────────────────────────────────── */
#bottom-panel {
height: var(--bottom-h);
min-height: var(--bottom-h);
background: var(--bg2);
border-top: 1px solid var(--border);
display: flex;
flex-direction: column;
flex-shrink: 0;
}
#bottom-tabs {
display: flex;
align-items: stretch;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.bottom-tab {
display: flex;
align-items: center;
gap: 6px;
padding: 0 14px;
height: 32px;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.04em;
color: var(--text3);
cursor: pointer;
border-right: 1px solid var(--border);
transition: color 0.1s;
user-select: none;
white-space: nowrap;
}
.bottom-tab:hover { color: var(--text2); }
.bottom-tab.active { color: var(--accent); border-bottom: 2px solid var(--accent); }
.bottom-tab .badge {
background: rgba(248,113,113,0.15);
color: var(--red);
border-radius: 10px;
padding: 1px 5px;
font-size: 9px;
font-weight: 700;
}
#bottom-content {
flex: 1;
overflow-y: auto;
padding: 8px 12px;
font-family: 'DM Mono', monospace;
font-size: 11.5px;
line-height: 1.6;
}
.bottom-panel-content { display: none; }
.bottom-panel-content.active { display: block; }
/* Build output lines */
.out-line { display: block; margin: 1px 0; }
.out-line.error { color: var(--red); }
.out-line.warning { color: var(--yellow); }
.out-line.success { color: var(--green); }
.out-line.info { color: var(--text2); }
.out-line.output { color: var(--text); }
/* Problems */
.problem-item {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 5px 0;
border-bottom: 1px solid var(--border);
}
.problem-item:last-child { border-bottom: none; }
.problem-icon { flex-shrink: 0; font-size: 11px; }
.problem-icon.error { color: var(--red); }
.problem-icon.warning { color: var(--yellow); }
.problem-msg { color: var(--text2); flex: 1; }
.problem-loc { color: var(--text3); font-size: 10px; white-space: nowrap; }
.no-problems { color: var(--text3); padding: 8px 0; }
/* Reasoning */
.reason-input-row {
display: flex;
gap: 8px;
margin-bottom: 8px;
flex-shrink: 0;
}
#reason-input {
flex: 1;
background: var(--bg3);
border: 1px solid var(--border2);
border-radius: var(--radius);
color: var(--text);
font-family: 'DM Mono', monospace;
font-size: 12px;
padding: 5px 10px;
outline: none;
}
#reason-input:focus { border-color: var(--accent); }
#reason-output { font-size: 12px; }
.verdict { font-weight: 700; margin-bottom: 4px; }
.verdict.supported { color: var(--green); }
.verdict.refuted { color: var(--red); }
.verdict.unresolved{ color: var(--text2); }
.confidence { color: var(--text3); font-size: 11px; margin-bottom: 8px; }
.evidence-list { list-style: none; }
.evidence-item {
padding: 3px 0 3px 10px;
border-left: 2px solid var(--border2);
color: var(--text2);
margin-bottom: 4px;
font-size: 11px;
}
/* Plugins */
.plugin-item {
display: flex;
align-items: center;
gap: 10px;
padding: 6px 0;
border-bottom: 1px solid var(--border);
}
.plugin-item:last-child { border-bottom: none; }
.plugin-name { font-weight: 600; color: var(--text); flex: 1; }
.plugin-desc { font-size: 11px; color: var(--text3); margin-top: 1px; }
.plugin-meta { display: flex; flex-direction: column; flex: 1; }
.plugin-badge {
padding: 2px 7px;
border-radius: 10px;
font-size: 9px;
font-weight: 700;
letter-spacing: 0.05em;
flex-shrink: 0;
}
.plugin-badge.installed { background: rgba(74,222,128,0.12); color: var(--green); }
.plugin-badge.available { background: var(--bg3); color: var(--text3); }
/* Scrollbars */
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.08); border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,0.15); }
/* Tooltip */
.tooltip {
position: fixed;
background: var(--bg4);
border: 1px solid var(--border2);
border-radius: var(--radius);
padding: 8px 12px;
font-family: 'DM Mono', monospace;
font-size: 12px;
color: var(--text);
max-width: 320px;
z-index: 1000;
pointer-events: none;
box-shadow: 0 4px 20px rgba(0,0,0,0.4);
white-space: pre-wrap;
}
.tooltip .type-label { color: var(--purple); font-weight: 600; }
</style>
</head>
<body>
<!-- ═══ HEADER ═══════════════════════════════════════════════════════════════ -->
<div id="header">
<div class="logo">
<div class="logo-dot"></div>
<div class="logo-mark">el<span>-ide</span></div>
</div>
<div class="header-sep"></div>
<div class="project-name" id="project-name-label">
project / <span id="project-name-span">hello</span>
</div>
<div class="header-spacer"></div>
<select class="target-select" id="target-select">
<option value="debug">debug</option>
<option value="release">release</option>
<option value="prod">prod</option>
</select>
<button class="btn btn-primary" id="btn-build">Build</button>
<button class="btn btn-run" id="btn-run">Run</button>
<button class="btn" id="btn-check">Check</button>
</div>
<!-- ═══ BODY ═════════════════════════════════════════════════════════════════ -->
<div id="body">
<div id="main-row">
<!-- ── FILE TREE ──────────────────────────────────────────────────────── -->
<div id="file-tree">
<div class="panel-header">Files</div>
<div id="file-list">
<div style="color:var(--text3);padding:12px;font-size:11px;">Loading...</div>
</div>
</div>
<!-- ── EDITOR ─────────────────────────────────────────────────────────── -->
<div id="editor-col">
<div id="editor-tabs">
<div class="editor-tab active" id="default-tab">
<span>main.el</span>
<div class="dot"></div>
</div>
</div>
<div id="editor-wrapper">
<div id="cm-editor"></div>
</div>
</div>
<!-- ── TYPE GRAPH ─────────────────────────────────────────────────────── -->
<div id="type-graph">
<div class="panel-header">Type Graph</div>
<canvas id="graph-canvas"></canvas>
<div id="graph-legend">
<div class="legend-item">
<div class="legend-dot" style="background:#7a8a9a"></div>
<span>builtin</span>
</div>
<div class="legend-item">
<div class="legend-dot" style="background:#38bdf8"></div>
<span>struct</span>
</div>
<div class="legend-item">
<div class="legend-dot" style="background:#a78bfa"></div>
<span>enum</span>
</div>
<div class="legend-item">
<div class="legend-dot" style="background:#4a5a6a"></div>
<span>primitive</span>
</div>
</div>
</div>
</div><!-- /#main-row -->
<!-- ═══ BOTTOM PANEL ═════════════════════════════════════════════════════ -->
<div id="bottom-panel">
<div id="bottom-tabs">
<div class="bottom-tab active" data-panel="build">Build Output</div>
<div class="bottom-tab" data-panel="problems">
Problems <span class="badge" id="problems-badge" style="display:none">0</span>
</div>
<div class="bottom-tab" data-panel="reasoning">Reasoning</div>
<div class="bottom-tab" data-panel="plugins">Plugins</div>
</div>
<div id="bottom-content">
<!-- Build output -->
<div id="panel-build" class="bottom-panel-content active">
<span class="out-line info">el-ide ready. Press Build to compile.</span>
</div>
<!-- Problems -->
<div id="panel-problems" class="bottom-panel-content">
<div class="no-problems">No problems detected.</div>
</div>
<!-- Reasoning -->
<div id="panel-reasoning" class="bottom-panel-content">
<div class="reason-input-row">
<input id="reason-input" type="text" placeholder="Enter a hypothesis about your code...">
<button class="btn btn-primary" id="btn-reason">Reason</button>
</div>
<div id="reason-output">
<div style="color:var(--text3);font-size:11px;">Enter a hypothesis and press Reason to query the Engram knowledge graph.</div>
</div>
</div>
<!-- Plugins -->
<div id="panel-plugins" class="bottom-panel-content">
<div id="plugin-list">Loading...</div>
</div>
</div><!-- /#bottom-content -->
</div>
</div><!-- /#body -->
<!-- Hover tooltip -->
<div class="tooltip" id="tooltip" style="display:none"></div>
<script type="module">
// ═══════════════════════════════════════════════════════════════════════════
// Imports — CodeMirror 6 via ESM CDN
// ═══════════════════════════════════════════════════════════════════════════
import { EditorView, basicSetup } from 'https://esm.sh/codemirror@6.0.1';
import { keymap } from 'https://esm.sh/@codemirror/view@6';
import { indentWithTab } from 'https://esm.sh/@codemirror/commands@6';
import { StreamLanguage } from 'https://esm.sh/@codemirror/language@6';
import { linter, lintGutter } from 'https://esm.sh/@codemirror/lint@6';
import { EditorState } from 'https://esm.sh/@codemirror/state@6';
import { oneDark } from 'https://esm.sh/@codemirror/theme-one-dark@6';
// ═══════════════════════════════════════════════════════════════════════════
// Engram-lang StreamLanguage definition for CodeMirror 6
// ═══════════════════════════════════════════════════════════════════════════
const KEYWORDS2 = new Set(['activate', 'sealed']); // special keywords
const KEYWORDS = new Set(['let','fn','type','enum','match','return','where','if','else','for','in','true','false']);
const BUILTINS = new Set(['Int','Float','String','Bool','Uuid','Void']);
const engramLang = StreamLanguage.define({
name: 'engram',
token(stream, state) {
// Skip whitespace
if (stream.eatSpace()) return null;
// Comments
if (stream.match('//')) {
stream.skipToEnd();
return 'comment';
}
// String literals
if (stream.eat('"')) {
while (!stream.eol()) {
if (stream.eat('\\')) { stream.next(); continue; }
if (stream.eat('"')) break;
stream.next();
}
return 'string';
}
// Numbers
if (stream.match(/^-?\d+(\.\d+)?/)) return 'number';
// Identifiers / keywords
if (stream.match(/^[A-Za-z_][A-Za-z0-9_]*/)) {
const word = stream.current();
if (KEYWORDS2.has(word)) return 'keyword2';
if (KEYWORDS.has(word)) return 'keyword';
if (BUILTINS.has(word)) return 'builtin';
// Capitalized identifiers = type names
if (/^[A-Z]/.test(word)) return 'type';
return 'variable';
}
// Operators & punctuation
if (stream.match('->') || stream.match('=>') || stream.match('::')
|| stream.match('==') || stream.match('!=') || stream.match('<=') || stream.match('>=')
|| stream.match('&&') || stream.match('||')) return 'operator';
if (stream.match(/^[+\-*\/=<>!&|]/)) return 'operator';
if (stream.match(/^[{}()\[\],.;:]/)) return 'punctuation';
stream.next();
return null;
},
startState() { return {}; },
copyState(s) { return { ...s }; },
blankLine() {},
indent() { return -1; },
languageData: {
commentTokens: { line: '//' },
},
});
// Map stream language token names to our CSS classes
function engramTheme() {
return EditorView.theme({
'&': { backgroundColor: 'var(--bg)', color: 'var(--text)' },
'.cm-content': { caretColor: 'var(--accent)' },
'.cm-cursor': { borderLeftColor: 'var(--accent)' },
'.cm-gutters': { background: 'var(--bg2)', borderRight: '1px solid var(--border)', color: 'var(--text3)' },
'.cm-activeLineGutter': { background: 'var(--bg3)' },
'.cm-activeLine': { background: 'rgba(56,189,248,0.04)' },
'.cm-selectionBackground': { background: 'rgba(56,189,248,0.15)' },
'.cm-matchingBracket': { background: 'rgba(56,189,248,0.12)', borderRadius: '2px' },
// token classes
'.tok-comment': { color: 'var(--text3)', fontStyle: 'italic' },
'.tok-string': { color: 'var(--green)' },
'.tok-number': { color: 'var(--orange)' },
'.tok-keyword': { color: 'var(--accent)' },
'.tok-keyword2': { color: 'var(--accent)', fontStyle: 'italic', fontWeight: '600' },
'.tok-type': { color: 'var(--purple)' },
'.tok-builtin': { color: 'var(--purple)', opacity: '0.8' },
'.tok-operator': { color: 'var(--text2)' },
'.tok-punctuation': { color: 'var(--text3)' },
'.tok-variable': { color: 'var(--text)' },
}, { dark: true });
}
// ═══════════════════════════════════════════════════════════════════════════
// IDE State
// ═══════════════════════════════════════════════════════════════════════════
const state = {
openFile: null,
unsaved: false,
currentSource: '',
lspDiagnostics: [],
graphData: { nodes: [], edges: [] },
graphOffsetX: 0,
graphOffsetY: 0,
graphScale: 1,
graphDragging: false,
graphDragStart: null,
graphNodes: [], // {id, x, y, vx, vy, ...}
graphAnimFrame: null,
};
// ═══════════════════════════════════════════════════════════════════════════
// Editor setup
// ═══════════════════════════════════════════════════════════════════════════
let debounceTimer = null;
const lspLinter = linter(async (view) => {
const source = view.state.doc.toString();
if (!source.trim()) return [];
try {
const params = new URLSearchParams({ source });
const resp = await fetch(`/api/lsp/errors?${params}`);
if (!resp.ok) return [];
const diags = await resp.json();
state.lspDiagnostics = diags;
updateProblemsPanel(diags);
return diags.map(d => ({
from: 0,
to: Math.min(source.length, 1),
severity: d.severity === 'error' ? 'error' : 'warning',
message: d.message,
}));
} catch { return []; }
}, { delay: 600 });
const editor = new EditorView({
parent: document.getElementById('cm-editor'),
extensions: [
basicSetup,
keymap.of([indentWithTab]),
StreamLanguage.define(engramLang.language.data).of({}),
engramLang,
engramTheme(),
lspLinter,
lintGutter(),
EditorView.updateListener.of(update => {
if (update.docChanged) {
state.unsaved = true;
state.currentSource = update.state.doc.toString();
markTabUnsaved(true);
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
refreshTypeGraph(state.currentSource);
}, 800);
}
}),
EditorView.domEventHandlers({
keydown(e, view) {
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
e.preventDefault();
saveCurrentFile();
return true;
}
return false;
},
mousemove(e, view) {
const pos = view.posAtCoords({ x: e.clientX, y: e.clientY });
if (pos != null) showHoverTooltip(e, view, pos);
},
mouseleave() {
hideTooltip();
},
}),
],
});
function setEditorContent(text) {
editor.dispatch({
changes: { from: 0, to: editor.state.doc.length, insert: text },
});
state.unsaved = false;
markTabUnsaved(false);
}
// ═══════════════════════════════════════════════════════════════════════════
// Hover tooltip
// ═══════════════════════════════════════════════════════════════════════════
const tooltip = document.getElementById('tooltip');
let hoverTimer = null;
async function showHoverTooltip(e, view, pos) {
clearTimeout(hoverTimer);
hoverTimer = setTimeout(async () => {
const source = view.state.doc.toString();
try {
const params = new URLSearchParams({ source, pos });
const resp = await fetch(`/api/lsp/hover?${params}`);
if (!resp.ok) { hideTooltip(); return; }
const info = await resp.json();
if (!info) { hideTooltip(); return; }
tooltip.innerHTML = `<span class="type-label">${escapeHtml(info.type_name)}</span>\n${escapeHtml(info.documentation)}`;
tooltip.style.display = 'block';
tooltip.style.left = (e.clientX + 16) + 'px';
tooltip.style.top = (e.clientY + 8) + 'px';
} catch { hideTooltip(); }
}, 400);
}
function hideTooltip() {
clearTimeout(hoverTimer);
tooltip.style.display = 'none';
}
// ═══════════════════════════════════════════════════════════════════════════
// File tree
// ═══════════════════════════════════════════════════════════════════════════
async function loadFileTree() {
try {
const resp = await fetch('/api/files?path=.');
if (!resp.ok) throw new Error(await resp.text());
const entries = await resp.json();
renderFileTree(entries);
} catch (err) {
document.getElementById('file-list').innerHTML =
`<div style="color:var(--red);padding:12px;font-size:11px;">Error: ${escapeHtml(String(err))}</div>`;
}
}
function renderFileTree(entries, container = null, depth = 0) {
const list = container || document.getElementById('file-list');
if (!container) list.innerHTML = '';
for (const entry of entries) {
const item = document.createElement('div');
item.className = 'file-item' + (entry.is_dir ? ' dir' : '');
item.dataset.path = entry.path;
item.style.paddingLeft = (12 + depth * 14) + 'px';
const icon = document.createElement('span');
icon.className = 'icon';
icon.textContent = entry.is_dir ? '▸' : '·';
item.appendChild(icon);
const name = document.createElement('span');
name.textContent = entry.name;
item.appendChild(name);
if (entry.is_dir) {
item.addEventListener('click', () => {
const isOpen = item.dataset.open === '1';
item.dataset.open = isOpen ? '0' : '1';
icon.textContent = isOpen ? '▸' : '▾';
// Toggle children
const childContainer = item.nextElementSibling;
if (childContainer && childContainer.dataset.children) {
childContainer.style.display = isOpen ? 'none' : '';
}
});
list.appendChild(item);
// Add child container
const childDiv = document.createElement('div');
childDiv.dataset.children = '1';
if (entry.children && entry.children.length > 0) {
renderFileTree(entry.children, childDiv, depth + 1);
}
list.appendChild(childDiv);
} else {
item.addEventListener('click', () => openFile(entry.path, entry.name));
list.appendChild(item);
}
}
}
async function openFile(path, name) {
// Mark active
document.querySelectorAll('.file-item').forEach(el => el.classList.remove('active'));
document.querySelector(`.file-item[data-path="${CSS.escape(path)}"]`)?.classList.add('active');
try {
const resp = await fetch(`/api/file?path=${encodeURIComponent(path)}`);
if (!resp.ok) throw new Error(await resp.text());
const { content } = await resp.json();
state.openFile = path;
state.currentSource = content;
setEditorContent(content);
// Update tab
document.getElementById('default-tab').querySelector('span').textContent = name || path;
// Refresh type graph
refreshTypeGraph(content);
} catch (err) {
appendBuildLine('error', `Failed to open ${path}: ${err}`);
switchBottomTab('build');
}
}
async function saveCurrentFile() {
if (!state.openFile) return;
const content = editor.state.doc.toString();
try {
const resp = await fetch('/api/file', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: state.openFile, content }),
});
if (!resp.ok) throw new Error(await resp.text());
state.unsaved = false;
markTabUnsaved(false);
appendBuildLine('success', `Saved ${state.openFile}`);
} catch (err) {
appendBuildLine('error', `Save failed: ${err}`);
}
}
function markTabUnsaved(unsaved) {
const tab = document.getElementById('default-tab');
tab.classList.toggle('unsaved', unsaved);
}
// ═══════════════════════════════════════════════════════════════════════════
// Build / Run
// ═══════════════════════════════════════════════════════════════════════════
function clearBuildOutput() {
document.getElementById('panel-build').innerHTML = '';
}
function appendBuildLine(type, text) {
const panel = document.getElementById('panel-build');
const line = document.createElement('span');
line.className = `out-line ${type}`;
line.textContent = text;
panel.appendChild(line);
panel.scrollTop = panel.scrollHeight;
}
async function runBuildOrRun(action) {
switchBottomTab('build');
clearBuildOutput();
appendBuildLine('info', `Starting ${action}...`);
const target = document.getElementById('target-select').value;
const body = action === 'build'
? { target, file: state.openFile || undefined }
: { file: state.openFile || 'src/main.el' };
const resp = await fetch(`/api/${action}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const reader = resp.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
// Parse SSE
const lines = buffer.split('\n');
buffer = lines.pop(); // keep incomplete line
let eventType = 'info';
for (const line of lines) {
if (line.startsWith('event:')) {
eventType = line.slice(6).trim();
} else if (line.startsWith('data:')) {
const data = line.slice(5).trim();
appendBuildLine(eventType, data);
if (eventType === 'done') {
const code = parseInt(data, 10);
appendBuildLine(code === 0 ? 'success' : 'error',
code === 0 ? '✓ Finished successfully.' : `✗ Exited with code ${code}.`);
}
eventType = 'info';
}
}
}
}
document.getElementById('btn-build').addEventListener('click', () => runBuildOrRun('build'));
document.getElementById('btn-run').addEventListener('click', () => runBuildOrRun('run'));
document.getElementById('btn-check').addEventListener('click', async () => {
switchBottomTab('problems');
const source = editor.state.doc.toString();
try {
const params = new URLSearchParams({ source });
const resp = await fetch(`/api/lsp/errors?${params}`);
const diags = await resp.json();
updateProblemsPanel(diags);
} catch (err) {
appendBuildLine('error', String(err));
}
});
// ═══════════════════════════════════════════════════════════════════════════
// Problems panel
// ═══════════════════════════════════════════════════════════════════════════
function updateProblemsPanel(diags) {
const panel = document.getElementById('panel-problems');
const badge = document.getElementById('problems-badge');
const errors = diags.filter(d => d.severity === 'error');
badge.textContent = errors.length;
badge.style.display = errors.length > 0 ? '' : 'none';
if (diags.length === 0) {
panel.innerHTML = '<div class="no-problems">No problems detected.</div>';
return;
}
panel.innerHTML = diags.map(d => `
<div class="problem-item">
<span class="problem-icon ${d.severity}">${d.severity === 'error' ? '●' : '▲'}</span>
<div class="problem-meta">
<span class="problem-msg">${escapeHtml(d.message)}</span>
${d.line ? `<span class="problem-loc">Line ${d.line}${d.col ? ':' + d.col : ''}</span>` : ''}
</div>
</div>
`).join('');
}
// ═══════════════════════════════════════════════════════════════════════════
// Type Graph — canvas force-directed renderer
// ═══════════════════════════════════════════════════════════════════════════
const canvas = document.getElementById('graph-canvas');
const ctx = canvas.getContext('2d');
const NODE_COLORS = {
builtin: '#4a5a6a',
struct: '#38bdf8',
enum: '#a78bfa',
primitive: '#3a4a5a',
};
function nodeColor(kind) {
return NODE_COLORS[kind] || '#7a8a9a';
}
function resizeCanvas() {
const rect = canvas.parentElement.getBoundingClientRect();
// Subtract legend height (~40px)
canvas.width = rect.width;
canvas.height = rect.height - 48;
}
async function refreshTypeGraph(source) {
try {
const params = new URLSearchParams({ source });
const resp = await fetch(`/api/type-graph?${params}`);
if (!resp.ok) return;
const graph = await resp.json();
state.graphData = graph;
initGraphLayout(graph);
drawGraph();
} catch { /* silently ignore */ }
}
function initGraphLayout(graph) {
const w = canvas.width || 280;
const h = canvas.height || 300;
const cx = w / 2, cy = h / 2;
const count = graph.nodes.length;
state.graphNodes = graph.nodes.map((n, i) => {
const angle = (2 * Math.PI * i) / Math.max(count, 1);
const r = Math.min(cx, cy) * 0.65;
return {
...n,
x: cx + r * Math.cos(angle),
y: cy + r * Math.sin(angle),
vx: 0, vy: 0,
};
});
// Run a few force-layout iterations synchronously
for (let iter = 0; iter < 60; iter++) {
tickForce();
}
}
function tickForce() {
const nodes = state.graphNodes;
const edges = state.graphData.edges || [];
const w = canvas.width || 280;
const h = canvas.height || 300;
const k = 80; // spring rest length
const repulsion = 3000;
// Reset forces
nodes.forEach(n => { n.fx = 0; n.fy = 0; });
// Repulsion
for (let i = 0; i < nodes.length; i++) {
for (let j = i + 1; j < nodes.length; j++) {
const dx = nodes[j].x - nodes[i].x;
const dy = nodes[j].y - nodes[i].y;
const dist = Math.max(Math.sqrt(dx*dx + dy*dy), 0.1);
const f = repulsion / (dist * dist);
const fx = f * dx / dist;
const fy = f * dy / dist;
nodes[i].fx -= fx; nodes[i].fy -= fy;
nodes[j].fx += fx; nodes[j].fy += fy;
}
}
// Attraction (edges)
const nodeIndex = Object.fromEntries(nodes.map(n => [n.id, n]));
for (const edge of edges) {
const a = nodeIndex[edge.from];
const b = nodeIndex[edge.to];
if (!a || !b) continue;
const dx = b.x - a.x;
const dy = b.y - a.y;
const dist = Math.max(Math.sqrt(dx*dx + dy*dy), 0.1);
const f = (dist - k) * 0.05;
const fx = f * dx / dist;
const fy = f * dy / dist;
a.fx += fx; a.fy += fy;
b.fx -= fx; b.fy -= fy;
}
// Center gravity
nodes.forEach(n => {
n.fx += (w/2 - n.x) * 0.01;
n.fy += (h/2 - n.y) * 0.01;
});
// Integrate
const damping = 0.7;
nodes.forEach(n => {
n.vx = (n.vx + n.fx) * damping;
n.vy = (n.vy + n.fy) * damping;
n.x += n.vx;
n.y += n.vy;
// Bounds
n.x = Math.max(24, Math.min(w - 24, n.x));
n.y = Math.max(18, Math.min(h - 18, n.y));
});
}
function drawGraph() {
const nodes = state.graphNodes;
const edges = state.graphData.edges || [];
const w = canvas.width;
const h = canvas.height;
ctx.clearRect(0, 0, w, h);
// Background
ctx.fillStyle = '#0d1117';
ctx.fillRect(0, 0, w, h);
if (nodes.length === 0) {
ctx.fillStyle = '#3a4a5a';
ctx.font = '11px DM Mono, monospace';
ctx.textAlign = 'center';
ctx.fillText('No types defined', w/2, h/2);
return;
}
const nodeIndex = Object.fromEntries(nodes.map(n => [n.id, n]));
// Draw edges
ctx.strokeStyle = 'rgba(255,255,255,0.08)';
ctx.lineWidth = 1;
for (const edge of edges) {
const a = nodeIndex[edge.from];
const b = nodeIndex[edge.to];
if (!a || !b) continue;
ctx.beginPath();
ctx.moveTo(a.x, a.y);
ctx.lineTo(b.x, b.y);
ctx.stroke();
// Arrow at target
const angle = Math.atan2(b.y - a.y, b.x - a.x);
const arrowLen = 6;
const tx = b.x - Math.cos(angle) * 12;
const ty = b.y - Math.sin(angle) * 12;
ctx.beginPath();
ctx.moveTo(tx, ty);
ctx.lineTo(tx - arrowLen * Math.cos(angle - 0.4), ty - arrowLen * Math.sin(angle - 0.4));
ctx.lineTo(tx - arrowLen * Math.cos(angle + 0.4), ty - arrowLen * Math.sin(angle + 0.4));
ctx.closePath();
ctx.fillStyle = 'rgba(255,255,255,0.15)';
ctx.fill();
}
// Draw nodes
ctx.font = 'bold 10px DM Mono, monospace';
ctx.textAlign = 'center';
for (const node of nodes) {
const color = nodeColor(node.kind);
const r = node.kind === 'builtin' ? 6 : 9;
// Node circle
ctx.beginPath();
ctx.arc(node.x, node.y, r, 0, Math.PI * 2);
ctx.fillStyle = color;
ctx.fill();
// Glow for user types
if (node.kind !== 'builtin' && node.kind !== 'primitive') {
ctx.shadowColor = color;
ctx.shadowBlur = 8;
ctx.beginPath();
ctx.arc(node.x, node.y, r, 0, Math.PI * 2);
ctx.fillStyle = color + '66';
ctx.fill();
ctx.shadowBlur = 0;
}
// Label
ctx.fillStyle = node.kind === 'builtin' ? '#4a5a6a' : '#e8edf3';
ctx.fillText(node.name, node.x, node.y - r - 4);
}
}
// Canvas interaction (pan)
canvas.addEventListener('mousedown', e => {
state.graphDragging = true;
state.graphDragStart = { x: e.clientX, y: e.clientY };
});
canvas.addEventListener('mousemove', e => {
if (!state.graphDragging) return;
const dx = e.clientX - state.graphDragStart.x;
const dy = e.clientY - state.graphDragStart.y;
state.graphDragStart = { x: e.clientX, y: e.clientY };
state.graphNodes.forEach(n => { n.x += dx; n.y += dy; });
drawGraph();
});
canvas.addEventListener('mouseup', () => { state.graphDragging = false; });
canvas.addEventListener('mouseleave', () => { state.graphDragging = false; });
// ═══════════════════════════════════════════════════════════════════════════
// Bottom tabs
// ═══════════════════════════════════════════════════════════════════════════
function switchBottomTab(name) {
document.querySelectorAll('.bottom-tab').forEach(t => t.classList.toggle('active', t.dataset.panel === name));
document.querySelectorAll('.bottom-panel-content').forEach(p =>
p.classList.toggle('active', p.id === `panel-${name}`)
);
if (name === 'plugins') loadPlugins();
}
document.querySelectorAll('.bottom-tab').forEach(tab =>
tab.addEventListener('click', () => switchBottomTab(tab.dataset.panel))
);
// ═══════════════════════════════════════════════════════════════════════════
// Plugins panel
// ═══════════════════════════════════════════════════════════════════════════
async function loadPlugins() {
const list = document.getElementById('plugin-list');
try {
const resp = await fetch('/api/plugins');
const plugins = await resp.json();
if (plugins.length === 0) {
list.innerHTML = '<div style="color:var(--text3)">No plugins.</div>';
return;
}
list.innerHTML = plugins.map(p => `
<div class="plugin-item">
<div class="plugin-meta">
<div class="plugin-name">${escapeHtml(p.name)} <span style="color:var(--text3);font-weight:400;font-size:10px;">v${escapeHtml(p.version)}</span></div>
<div class="plugin-desc">${escapeHtml(p.description)}</div>
</div>
<span class="plugin-badge ${p.installed ? 'installed' : 'available'}">${p.installed ? 'installed' : 'available'}</span>
${p.installed
? `<button class="btn" style="font-size:10px;padding:3px 8px" onclick="pluginRemove('${escapeHtml(p.name)}')">Remove</button>`
: `<button class="btn btn-primary" style="font-size:10px;padding:3px 8px" onclick="pluginInstall('${escapeHtml(p.name)}')">Install</button>`
}
</div>
`).join('');
} catch (err) {
list.innerHTML = `<div style="color:var(--red)">Error: ${escapeHtml(String(err))}</div>`;
}
}
window.pluginInstall = async (name) => {
await fetch('/api/plugins/install', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name }),
});
loadPlugins();
};
window.pluginRemove = async (name) => {
await fetch(`/api/plugins/${encodeURIComponent(name)}`, { method: 'DELETE' });
loadPlugins();
};
// ═══════════════════════════════════════════════════════════════════════════
// Reasoning panel
// ═══════════════════════════════════════════════════════════════════════════
document.getElementById('btn-reason').addEventListener('click', async () => {
const input = document.getElementById('reason-input');
const hypothesis = input.value.trim();
if (!hypothesis) return;
const output = document.getElementById('reason-output');
output.innerHTML = '<span style="color:var(--text3)">Querying Engram reasoning engine...</span>';
try {
const resp = await fetch('/api/reason', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
hypothesis,
context: editor.state.doc.toString().slice(0, 2000),
}),
});
const result = await resp.json();
const verdictClass = result.verdict === 'supported' ? 'supported'
: result.verdict === 'refuted' ? 'refuted' : 'unresolved';
const evidenceHtml = (result.evidence || []).map(e =>
`<li class="evidence-item">${escapeHtml(e.text)} <span style="color:var(--text3)">(weight: ${(e.weight || 0).toFixed(2)})</span></li>`
).join('');
output.innerHTML = `
<div class="verdict ${verdictClass}">${result.verdict.toUpperCase()}</div>
<div class="confidence">Confidence: ${((result.confidence || 0) * 100).toFixed(1)}% — source: ${escapeHtml(result.source || '')}</div>
${evidenceHtml ? `<ul class="evidence-list">${evidenceHtml}</ul>` : ''}
`;
} catch (err) {
output.innerHTML = `<span style="color:var(--red)">Error: ${escapeHtml(String(err))}</span>`;
}
});
document.getElementById('reason-input').addEventListener('keydown', e => {
if (e.key === 'Enter') document.getElementById('btn-reason').click();
});
// ═══════════════════════════════════════════════════════════════════════════
// Utilities
// ═══════════════════════════════════════════════════════════════════════════
function escapeHtml(str) {
return String(str).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
// ═══════════════════════════════════════════════════════════════════════════
// Init
// ═══════════════════════════════════════════════════════════════════════════
async function init() {
resizeCanvas();
window.addEventListener('resize', () => { resizeCanvas(); drawGraph(); });
// Load file tree
await loadFileTree();
// Try to auto-open src/main.el
try {
const resp = await fetch('/api/file?path=src/main.el');
if (resp.ok) {
const { content } = await resp.json();
state.openFile = 'src/main.el';
state.currentSource = content;
setEditorContent(content);
document.querySelector('.file-item[data-path="src/main.el"]')?.classList.add('active');
await refreshTypeGraph(content);
}
} catch { /* no main.el */ }
// Initial type graph draw (empty)
drawGraph();
}
init();
</script>
</body>
</html>