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
Will Anderson 65e74d6474 El IDE: rounds 14-20 — breadcrumb nav, version display, improved search, sticky scroll, status bar diagnostics
Round 14: Breadcrumb directory click — clicking a path segment in the breadcrumb expands/reveals that directory in the file tree
Round 15: El version in status bar — GET /api/status now returns el_version (via el --version), shown in status bar right side; EL_BINARY config env var
Round 16: Search improvements — case-sensitive, whole-word, regex toggles (Alt+C/W/R); project-wide replace-all in current file; backend SearchOpts struct for each mode
Round 17: Sticky scroll improvements — uses CM6 posAtCoords for accurate first-visible-line; clickable to jump to definition; sticky-name/sticky-goto styling
Round 18: File tree header — New File (+) button and Refresh (↺) button in file tree header panel
Round 19: Status bar diagnostics — error count (✕ N) and warning count (⚠ N) shown in status bar, clickable to jump to problems panel
Round 20: Polish — more El snippets (test, seed, assert, activate, parallel, deploy, import, with, retry, reason, trace), expanded command palette (11 new commands)
2026-04-29 04:38:53 -05:00

5482 lines
201 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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>
/* ── THEME VARIABLES ────────────────────────────────────────────────────────── */
: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;
}
html.theme-light {
--bg: #f4f6f9;
--bg2: #ffffff;
--bg3: #f0f2f5;
--bg4: #e8ecf0;
--border: rgba(0,0,0,0.07);
--border2: rgba(0,0,0,0.13);
--text: #1a2233;
--text2: #3d4f62;
--text3: #8896a4;
--accent: #0077cc;
--accent-dim: rgba(0,119,204,0.10);
--accent-glow: rgba(0,119,204,0.25);
--purple: #6d28d9;
--green: #15803d;
--orange: #c2410c;
--red: #b91c1c;
--yellow: #b45309;
}
/* Light theme refinements */
html.theme-light body { background: var(--bg); }
html.theme-light #header {
background: #ffffff;
box-shadow: 0 1px 3px rgba(0,0,0,0.08), 0 1px 0 rgba(0,0,0,0.04);
border-bottom: none;
}
html.theme-light .cm-gutters { color: #94a3b8 !important; }
html.theme-light .cm-activeLine { background: rgba(0,119,204,0.04) !important; }
html.theme-light .cm-selectionBackground { background: rgba(0,119,204,0.15) !important; }
html.theme-light .cm-matchingBracket { background: rgba(0,119,204,0.12) !important; }
html.theme-light #bottom-panel {
box-shadow: 0 -1px 3px rgba(0,0,0,0.05);
}
html.theme-light #status-bar { background: var(--bg2); }
html.theme-light #status-server { color: #15803d; }
html.theme-light ::-webkit-scrollbar-thumb { background: rgba(0,0,0,0.12); }
html.theme-light ::-webkit-scrollbar-thumb:hover { background: rgba(0,0,0,0.22); }
html.theme-light .cm-tooltip { background: #ffffff !important; box-shadow: 0 4px 16px rgba(0,0,0,0.12) !important; }
html.theme-light .cm-tooltip-autocomplete li { color: #1a2233 !important; }
html.theme-light .cm-tooltip-autocomplete li[aria-selected] { background: rgba(0,119,204,0.10) !important; color: #0077cc !important; }
html.theme-neuron {
--bg: #060810;
--bg2: #09091a;
--bg3: #0c0d1e;
--bg4: #100f24;
--border: rgba(139,92,246,0.14);
--border2: rgba(139,92,246,0.28);
--text: #e4dfff;
--text2: #9b8ed8;
--text3: #52448a;
--accent: #8b5cf6;
--accent-dim: rgba(139,92,246,0.18);
--accent-glow: rgba(139,92,246,0.55);
--purple: #c084fc;
--green: #34d399;
--orange: #fb923c;
--red: #f87171;
--yellow: #fbbf24;
}
/* Neuron theme — ambient glow effects */
html.theme-neuron #header {
background: linear-gradient(135deg, #09091a 0%, #0d0820 100%);
box-shadow: 0 1px 0 rgba(139,92,246,0.2), 0 4px 20px rgba(139,92,246,0.04);
}
html.theme-neuron #file-tree { box-shadow: inset -1px 0 0 rgba(139,92,246,0.1); }
html.theme-neuron .logo-dot {
background: var(--accent);
box-shadow: 0 0 12px var(--accent), 0 0 24px rgba(139,92,246,0.4), 0 0 40px rgba(139,92,246,0.2);
}
html.theme-neuron .btn-primary {
box-shadow: 0 0 8px rgba(139,92,246,0.3);
}
html.theme-neuron .btn-primary:hover {
box-shadow: 0 0 14px rgba(139,92,246,0.5), 0 0 28px rgba(139,92,246,0.2);
}
html.theme-neuron .editor-tab.active {
box-shadow: 0 -1px 8px rgba(139,92,246,0.15);
}
html.theme-neuron .cm-activeLine { background: rgba(139,92,246,0.06) !important; }
html.theme-neuron .cm-selectionBackground { background: rgba(139,92,246,0.2) !important; }
html.theme-neuron .cm-matchingBracket { background: rgba(139,92,246,0.18) !important; }
html.theme-neuron #status-bar {
background: linear-gradient(90deg, #09091a 0%, #0c0820 100%);
}
html.theme-neuron #bottom-panel {
box-shadow: 0 -1px 0 rgba(139,92,246,0.15);
}
@keyframes neuron-pulse {
0%,100% { opacity: 0.4; }
50% { opacity: 0.8; }
}
html.theme-neuron .logo-dot {
animation: pulse-dot 2s ease-in-out infinite, neuron-pulse 4s ease-in-out infinite;
}
html.theme-high-contrast {
--bg: #000000;
--bg2: #0a0a0a;
--bg3: #111111;
--bg4: #1a1a1a;
--border: rgba(255,255,255,0.25);
--border2: rgba(255,255,255,0.45);
--text: #ffffff;
--text2: #cccccc;
--text3: #888888;
--accent: #ffffff;
--accent-dim: rgba(255,255,255,0.1);
--accent-glow: rgba(255,255,255,0.3);
--purple: #dd88ff;
--green: #44ff88;
--orange: #ffaa44;
--red: #ff4444;
--yellow: #ffee44;
}
* { 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: 10px;
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); flex-shrink: 0; }
.header-spacer { flex: 1; }
.project-name {
font-family: 'DM Mono', monospace;
font-size: 12px;
color: var(--text2);
flex-shrink: 0;
}
.project-name span { color: var(--text); }
.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: var(--accent-dim);
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); }
/* Theme dropdown */
.theme-dropdown { position: relative; }
.theme-menu {
position: absolute;
top: calc(100% + 6px);
right: 0;
background: var(--bg2);
border: 1px solid var(--border2);
border-radius: var(--radius);
box-shadow: 0 8px 24px rgba(0,0,0,0.4);
z-index: 500;
min-width: 140px;
overflow: hidden;
}
.theme-option {
padding: 7px 14px;
font-size: 12px;
color: var(--text2);
cursor: pointer;
transition: background 0.1s;
}
.theme-option:hover { background: var(--bg3); color: var(--text); }
.theme-option.active { color: var(--accent); background: var(--accent-dim); }
/* ── BREADCRUMB ─────────────────────────────────────────────────────────────── */
#breadcrumb {
height: 24px;
min-height: 24px;
background: var(--bg2);
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
padding: 0 14px;
font-family: 'DM Mono', monospace;
font-size: 11px;
color: var(--text3);
gap: 4px;
flex-shrink: 0;
overflow: hidden;
}
.crumb {
color: var(--text2);
cursor: pointer;
transition: color 0.1s;
}
.crumb:hover { color: var(--accent); }
.crumb-sep { color: var(--text3); }
.crumb-current { color: var(--text); }
/* ── 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;
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; }
/* Git status badges in file tree */
.git-badge {
font-size: 9px;
font-weight: 700;
padding: 1px 4px;
border-radius: 3px;
margin-left: auto;
flex-shrink: 0;
letter-spacing: 0.04em;
}
.git-badge-M { background: rgba(251,191,36,0.15); color: var(--yellow); }
.git-badge-A { background: rgba(74,222,128,0.12); color: var(--green); }
.git-badge-D { background: rgba(248,113,113,0.12); color: var(--red); }
.git-badge-R { background: rgba(167,139,250,0.12); color: var(--purple); }
/* ── 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: hidden;
flex-shrink: 0;
position: relative;
}
/* Tabs scroll container */
#editor-tabs-scroll {
display: flex;
align-items: stretch;
flex: 1;
overflow-x: auto;
scrollbar-width: none;
}
#editor-tabs-scroll::-webkit-scrollbar { display: none; }
/* Overflow button (▾) */
#tab-overflow-btn {
flex-shrink: 0;
width: 28px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
color: var(--text3);
border-left: 1px solid var(--border);
font-size: 10px;
transition: color 0.1s;
}
#tab-overflow-btn:hover { color: var(--text); background: var(--bg3); }
#tab-overflow-menu {
position: absolute;
top: 32px;
right: 0;
background: var(--bg2);
border: 1px solid var(--border2);
border-radius: var(--radius);
box-shadow: 0 8px 24px rgba(0,0,0,0.4);
z-index: 500;
min-width: 180px;
max-height: 240px;
overflow-y: auto;
}
.tab-overflow-item {
padding: 7px 14px;
font-family: 'DM Mono', monospace;
font-size: 11.5px;
color: var(--text2);
cursor: pointer;
transition: background 0.1s;
display: flex;
align-items: center;
gap: 6px;
}
.tab-overflow-item:hover { background: var(--bg3); color: var(--text); }
.tab-overflow-item.active-tab { color: var(--accent); }
.editor-tab {
display: flex;
align-items: center;
gap: 6px;
padding: 0 12px;
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;
position: relative;
flex-shrink: 0;
}
.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;
flex-shrink: 0;
}
.editor-tab.unsaved .dot { display: block; }
.tab-close {
width: 14px; height: 14px;
border-radius: 2px;
display: flex;
align-items: center;
justify-content: center;
font-size: 10px;
color: var(--text3);
flex-shrink: 0;
transition: all 0.1s;
line-height: 1;
}
.tab-close:hover { background: var(--bg3); color: var(--red); }
#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; }
/* Autocompletion */
.cm-tooltip { background: var(--bg2) !important; border: 1px solid var(--border2) !important; }
.cm-tooltip-autocomplete ul { background: var(--bg2) !important; }
.cm-tooltip-autocomplete li { color: var(--text2) !important; font-family: 'DM Mono', monospace !important; font-size: 12px !important; }
.cm-tooltip-autocomplete li[aria-selected] { background: var(--accent-dim) !important; color: var(--accent) !important; }
.cm-completionIcon { opacity: 0.6; }
/* Syntax tokens */
.tok-keyword { color: var(--accent); }
.tok-keyword2 { color: var(--accent); font-style: italic; }
.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;
}
/* Code folding gutter */
.cm-foldGutter span { color: var(--text3); cursor: pointer; font-size: 12px; line-height: 1; }
.cm-foldGutter span:hover { color: var(--accent); }
/* Word highlight */
.cm-wordHighlight { background: rgba(56,189,248,0.12); border-radius: 2px; }
/* Find/Replace panel */
.cm-search { background: var(--bg2) !important; border-top: 1px solid var(--border2) !important; padding: 6px 10px !important; }
.cm-search input { background: var(--bg3) !important; border: 1px solid var(--border2) !important; border-radius: var(--radius) !important; color: var(--text) !important; font-family: 'DM Mono', monospace !important; font-size: 12px !important; padding: 3px 8px !important; margin: 2px 4px 2px 0 !important; }
.cm-search input:focus { border-color: var(--accent) !important; outline: none !important; }
.cm-search button { background: var(--bg3) !important; border: 1px solid var(--border2) !important; border-radius: var(--radius) !important; color: var(--text2) !important; font-family: 'Syne', sans-serif !important; font-size: 11px !important; padding: 3px 8px !important; cursor: pointer !important; margin: 0 2px !important; }
.cm-search button:hover { border-color: var(--accent) !important; color: var(--accent) !important; }
.cm-search label { color: var(--text3) !important; font-size: 11px !important; }
.cm-searchMatch { background: rgba(251,191,36,0.25) !important; border-radius: 2px; }
.cm-searchMatch.cm-searchMatch-selected { background: rgba(251,191,36,0.5) !important; }
/* ── STATUS BAR ─────────────────────────────────────────────────────────────── */
#status-bar {
height: 22px;
min-height: 22px;
background: var(--bg2);
border-top: 1px solid var(--border);
display: flex;
align-items: center;
padding: 0 12px;
gap: 0;
font-family: 'DM Mono', monospace;
font-size: 11px;
color: var(--text3);
flex-shrink: 0;
}
.status-sep { margin: 0 8px; opacity: 0.4; }
.status-spacer { flex: 1; }
#status-server { color: var(--green); }
/* ── NEURON PAIR PANEL ──────────────────────────────────────────────────────── */
#neuron-panel {
width: 300px;
min-width: 300px;
background: var(--bg2);
border-left: 1px solid var(--border);
display: flex;
flex-direction: column;
overflow: hidden;
}
.neuron-panel-header {
height: 40px;
min-height: 40px;
display: flex;
align-items: center;
padding: 0 12px;
gap: 8px;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
background: var(--bg2);
}
.neuron-panel-title {
font-family: 'Syne', sans-serif;
font-size: 12px;
font-weight: 700;
color: var(--purple);
letter-spacing: 0.04em;
flex: 1;
}
.neuron-panel-status {
font-family: 'DM Mono', monospace;
font-size: 9px;
color: var(--text3);
letter-spacing: 0.02em;
}
.neuron-status-dot {
width: 6px; height: 6px;
border-radius: 50%;
background: var(--text3);
flex-shrink: 0;
}
.neuron-status-dot.connected {
background: var(--green);
box-shadow: 0 0 6px rgba(74,222,128,0.5);
animation: pulse-dot 2.5s ease-in-out infinite;
}
.neuron-status-dot.active {
background: var(--purple);
box-shadow: 0 0 8px rgba(167,139,250,0.6);
animation: pulse-dot 1.5s ease-in-out infinite;
}
#neuron-messages {
flex: 1;
overflow-y: auto;
padding: 8px;
display: flex;
flex-direction: column;
gap: 8px;
}
.neuron-msg {
display: flex;
flex-direction: column;
gap: 4px;
max-width: 100%;
}
.neuron-msg-user {
align-self: flex-end;
background: var(--accent-dim);
border: 1px solid rgba(56,189,248,0.2);
border-radius: 8px 8px 2px 8px;
padding: 7px 10px;
font-family: 'DM Mono', monospace;
font-size: 11.5px;
color: var(--text);
max-width: 90%;
}
.neuron-msg-neuron {
align-self: flex-start;
background: rgba(139,92,246,0.08);
border: 1px solid rgba(139,92,246,0.15);
border-radius: 2px 8px 8px 8px;
padding: 8px 10px;
font-family: 'DM Mono', monospace;
font-size: 11.5px;
color: var(--text);
max-width: 100%;
line-height: 1.5;
white-space: pre-wrap;
word-break: break-word;
}
.neuron-msg-label {
font-size: 9px;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
padding: 0 2px;
}
.neuron-msg-label.user-label { color: var(--accent); }
.neuron-msg-label.neuron-label { color: var(--purple); }
.neuron-code-block {
background: var(--bg3);
border: 1px solid var(--border2);
border-radius: 4px;
margin: 6px 0;
overflow: hidden;
}
.neuron-code-header {
display: flex;
align-items: center;
padding: 4px 10px;
border-bottom: 1px solid var(--border);
background: var(--bg4);
gap: 6px;
}
.neuron-code-lang {
font-size: 9px;
font-weight: 700;
letter-spacing: 0.04em;
color: var(--purple);
text-transform: uppercase;
flex: 1;
}
.neuron-code-insert {
font-family: 'Syne', sans-serif;
font-size: 10px;
font-weight: 600;
color: var(--accent);
cursor: pointer;
padding: 2px 6px;
border-radius: 3px;
border: 1px solid rgba(56,189,248,0.3);
background: var(--accent-dim);
transition: all 0.1s;
white-space: nowrap;
}
.neuron-code-insert:hover {
background: rgba(56,189,248,0.2);
border-color: var(--accent);
}
.neuron-code-body {
padding: 8px 10px;
font-family: 'DM Mono', monospace;
font-size: 11px;
color: var(--text);
white-space: pre;
overflow-x: auto;
line-height: 1.6;
}
.neuron-thinking {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 10px;
color: var(--text3);
font-family: 'DM Mono', monospace;
font-size: 11px;
}
.thinking-dots span {
display: inline-block;
animation: thinking-bounce 1.2s ease-in-out infinite;
font-size: 18px;
line-height: 1;
}
.thinking-dots span:nth-child(2) { animation-delay: 0.2s; }
.thinking-dots span:nth-child(3) { animation-delay: 0.4s; }
@keyframes thinking-bounce {
0%, 60%, 100% { transform: translateY(0); opacity: 0.4; }
30% { transform: translateY(-4px); opacity: 1; }
}
#neuron-input-row {
border-top: 1px solid var(--border);
padding: 8px;
display: flex;
flex-direction: column;
gap: 6px;
flex-shrink: 0;
background: var(--bg2);
}
#neuron-input {
background: var(--bg3);
border: 1px solid var(--border2);
border-radius: var(--radius);
color: var(--text);
font-family: 'DM Mono', monospace;
font-size: 11.5px;
padding: 7px 10px;
outline: none;
resize: none;
min-height: 36px;
max-height: 80px;
line-height: 1.4;
width: 100%;
overflow-y: auto;
}
#neuron-input:focus { border-color: var(--purple); }
.neuron-quick-actions {
display: flex;
gap: 4px;
flex-wrap: wrap;
}
.neuron-quick-btn {
font-family: 'Syne', sans-serif;
font-size: 9.5px;
font-weight: 600;
padding: 3px 7px;
border-radius: 3px;
border: 1px solid var(--border2);
background: var(--bg3);
color: var(--text3);
cursor: pointer;
transition: all 0.12s;
white-space: nowrap;
letter-spacing: 0.02em;
}
.neuron-quick-btn:hover {
border-color: var(--purple);
color: var(--purple);
background: rgba(139,92,246,0.08);
}
#neuron-send {
font-family: 'Syne', sans-serif;
font-size: 11px;
font-weight: 700;
padding: 6px 14px;
border-radius: var(--radius);
border: 1px solid rgba(139,92,246,0.4);
background: rgba(139,92,246,0.12);
color: var(--purple);
cursor: pointer;
transition: all 0.15s;
align-self: flex-end;
margin-top: 2px;
}
#neuron-send:hover {
background: rgba(139,92,246,0.22);
box-shadow: 0 0 12px rgba(139,92,246,0.3);
}
/* Neuron panel toggle button (in header) */
#btn-neuron-toggle {
position: relative;
}
#btn-neuron-toggle.neuron-active {
background: rgba(139,92,246,0.12);
border-color: rgba(139,92,246,0.4);
color: var(--purple);
box-shadow: 0 0 8px rgba(139,92,246,0.2);
}
/* ── ACTIVATION PREVIEW ─────────────────────────────────────────────────────── */
#activation-preview {
position: absolute;
z-index: 200;
background: var(--bg2);
border: 1px solid rgba(139,92,246,0.4);
border-radius: 6px;
padding: 8px 12px;
font-family: 'DM Mono', monospace;
font-size: 11px;
box-shadow: 0 4px 20px rgba(0,0,0,0.5), 0 0 24px rgba(139,92,246,0.1);
display: none;
max-width: 280px;
pointer-events: none;
}
#activation-preview.visible { display: block; }
.ap-header {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 6px;
}
.ap-icon { color: var(--purple); font-size: 12px; }
.ap-count { color: var(--text); font-weight: 600; }
.ap-type { color: var(--purple); }
.ap-query { color: var(--text3); font-size: 10px; }
.ap-nodes {
display: flex;
flex-direction: column;
gap: 3px;
}
.ap-node {
display: flex;
align-items: center;
gap: 6px;
padding: 2px 0;
border-bottom: 1px solid var(--border);
}
.ap-node:last-child { border-bottom: none; }
.ap-node-label { color: var(--text2); flex: 1; }
.ap-node-score {
font-size: 9.5px;
color: var(--purple);
padding: 1px 4px;
border-radius: 2px;
background: rgba(139,92,246,0.12);
}
.ap-disconnected { color: var(--text3); font-size: 10px; font-style: italic; }
/* ── SEMANTIC COMPLETION BADGE ──────────────────────────────────────────────── */
.cm-semantic-completion {
position: relative;
}
/* ── TYPE GRAPH ─────────────────────────────────────────────────────────────── */
#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; }
/* Type graph node details */
#graph-node-detail {
padding: 6px 12px;
border-top: 1px solid var(--border);
font-family: 'DM Mono', monospace;
font-size: 11px;
color: var(--text2);
flex-shrink: 0;
min-height: 28px;
display: none;
}
#graph-node-detail.visible { display: block; }
.gnd-name { color: var(--accent); font-weight: 600; }
.gnd-kind { color: var(--text3); }
/* Graph zoom controls */
#graph-controls {
display: flex;
gap: 4px;
padding: 4px 8px;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
align-items: center;
}
.graph-btn {
background: var(--bg3);
border: 1px solid var(--border2);
border-radius: 3px;
color: var(--text2);
font-size: 13px;
width: 22px;
height: 22px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.1s;
line-height: 1;
}
.graph-btn:hover { border-color: var(--accent); color: var(--accent); }
.graph-zoom-label { font-family: 'DM Mono', monospace; font-size: 10px; color: var(--text3); margin-left: 2px; }
/* ── MINIMAP ──────────────────────────────────────────────────────────────── */
#minimap-container {
position: absolute;
top: 0; right: 0;
width: 60px;
height: 100%;
pointer-events: all;
overflow: hidden;
z-index: 10;
opacity: 0.6;
cursor: pointer;
transition: opacity 0.15s;
}
#minimap-container:hover { opacity: 0.9; }
#minimap-canvas {
width: 100%;
height: 100%;
display: block;
}
#minimap-viewport {
position: absolute;
right: 0;
width: 100%;
background: rgba(56,189,248,0.1);
border: 1px solid rgba(56,189,248,0.3);
pointer-events: none;
}
/* ── RESIZABLE PANELS ─────────────────────────────────────────────────────── */
.panel-resize-handle {
width: 4px;
background: transparent;
cursor: ew-resize;
flex-shrink: 0;
transition: background 0.15s;
z-index: 20;
}
.panel-resize-handle:hover { background: var(--accent-dim); }
#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 */
.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);
cursor: pointer;
transition: background 0.1s;
border-radius: 2px;
}
.problem-item:hover { background: var(--bg3); padding-left: 4px; }
.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; }
/* Search panel */
.search-input-row { display: flex; gap: 8px; margin-bottom: 8px; }
#search-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;
}
#search-input:focus { border-color: var(--accent); }
#search-replace-input {
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;
}
#search-replace-input:focus { border-color: var(--accent); }
.search-toggle-btn {
background: var(--bg3);
border: 1px solid var(--border2);
border-radius: var(--radius);
color: var(--text2);
font-family: 'DM Mono', monospace;
font-size: 11px;
padding: 0 7px;
height: 28px;
cursor: pointer;
transition: background 0.15s, border-color 0.15s, color 0.15s;
flex-shrink: 0;
}
.search-toggle-btn:hover { border-color: var(--accent); color: var(--text); }
.search-toggle-btn.active { background: var(--accent-dim); border-color: var(--accent); color: var(--accent); }
.search-result {
display: flex;
align-items: baseline;
gap: 8px;
padding: 4px 0;
border-bottom: 1px solid var(--border);
cursor: pointer;
transition: background 0.1s;
border-radius: 2px;
}
.search-result:hover { background: var(--bg3); }
.search-result:last-child { border-bottom: none; }
.search-path { color: var(--accent); font-size: 10px; flex-shrink: 0; }
.search-loc { color: var(--text3); font-size: 10px; flex-shrink: 0; }
.search-snippet { color: var(--text2); font-size: 11px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; }
/* Reasoning */
.reason-input-row { display: flex; gap: 8px; margin-bottom: 8px; }
#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;
}
/* Knowledge Graph explorer panel */
.kg-search-row { display: flex; gap: 8px; margin-bottom: 8px; }
#kg-search-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;
}
#kg-search-input:focus { border-color: var(--purple); box-shadow: 0 0 0 1px rgba(139,92,246,0.2); }
.kg-node-card {
border: 1px solid var(--border);
border-radius: 4px;
padding: 8px 10px;
margin-bottom: 6px;
cursor: pointer;
transition: all 0.15s;
background: var(--bg3);
}
.kg-node-card:hover { border-color: var(--purple); background: rgba(139,92,246,0.06); }
.kg-node-header { display: flex; align-items: center; gap: 8px; margin-bottom: 4px; }
.kg-node-label { font-weight: 600; color: var(--text); font-family: 'DM Mono', monospace; font-size: 12px; flex: 1; }
.kg-node-type {
font-size: 9px;
font-weight: 700;
letter-spacing: 0.04em;
padding: 1px 5px;
border-radius: 3px;
background: rgba(139,92,246,0.12);
color: var(--purple);
text-transform: uppercase;
flex-shrink: 0;
}
.kg-node-score {
font-size: 10px;
color: var(--text3);
font-family: 'DM Mono', monospace;
flex-shrink: 0;
}
.kg-node-id { font-size: 10px; color: var(--text3); font-family: 'DM Mono', monospace; }
.kg-node-actions { display: flex; gap: 6px; margin-top: 6px; }
.kg-use-btn {
font-family: 'Syne', sans-serif;
font-size: 9.5px;
font-weight: 600;
padding: 2px 8px;
border-radius: 3px;
border: 1px solid rgba(139,92,246,0.3);
background: rgba(139,92,246,0.08);
color: var(--purple);
cursor: pointer;
transition: all 0.1s;
}
.kg-use-btn:hover { background: rgba(139,92,246,0.18); }
.kg-status-msg { color: var(--text3); font-size: 11px; padding: 8px 0; font-style: italic; }
/* Plugins */
.plugin-section-title {
font-size: 10px;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text3);
padding: 8px 0 4px;
border-bottom: 1px solid var(--border);
margin-bottom: 4px;
}
.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); }
.plugin-desc { font-size: 11px; color: var(--text3); margin-top: 1px; }
.plugin-meta { display: flex; flex-direction: column; flex: 1; }
.plugin-badges { display: flex; gap: 4px; flex-wrap: wrap; margin-top: 3px; }
.plugin-badge {
padding: 1px 6px;
border-radius: 10px;
font-size: 9px;
font-weight: 700;
letter-spacing: 0.04em;
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); }
.plugin-badge.first-party { background: rgba(56,189,248,0.10); color: var(--accent); }
.plugin-badge.hook { background: rgba(167,139,250,0.12); color: var(--purple); font-size: 8px; }
/* Settings */
.settings-section { margin-bottom: 16px; }
.settings-title {
font-size: 10px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text3);
padding-bottom: 6px;
border-bottom: 1px solid var(--border);
margin-bottom: 8px;
}
.settings-row {
display: flex;
align-items: center;
gap: 10px;
padding: 4px 0;
font-size: 12px;
}
.settings-row label { color: var(--text2); flex: 1; }
.settings-row input[type="range"] { width: 100px; accent-color: var(--accent); cursor: pointer; }
.settings-row 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: 3px 8px;
cursor: pointer;
outline: none;
}
.settings-row input[type="checkbox"] { accent-color: var(--accent); cursor: pointer; width: 14px; height: 14px; }
.settings-val { color: var(--text3); font-size: 11px; width: 32px; }
.settings-theme-grid { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 4px; }
.settings-theme-btn {
padding: 5px 14px;
border-radius: var(--radius);
border: 1px solid var(--border2);
background: var(--bg3);
color: var(--text2);
font-family: 'Syne', sans-serif;
font-size: 11px;
cursor: pointer;
transition: all 0.15s;
}
.settings-theme-btn:hover { border-color: var(--accent); color: var(--accent); }
.settings-theme-btn.active { background: var(--accent-dim); border-color: var(--accent); color: var(--accent); }
/* Shortcuts */
.shortcuts-table { width: 100%; border-collapse: collapse; font-size: 12px; }
.shortcuts-table tr { border-bottom: 1px solid var(--border); }
.shortcuts-table tr:last-child { border-bottom: none; }
.shortcuts-table td { padding: 5px 0; }
.shortcuts-table td:first-child { color: var(--text2); width: 50%; }
.shortcuts-table kbd {
background: var(--bg3);
border: 1px solid var(--border2);
border-radius: 3px;
padding: 1px 5px;
font-family: 'DM Mono', monospace;
font-size: 10px;
color: var(--text);
}
/* Terminal panel */
#panel-terminal {
height: 100%;
overflow: hidden;
background: #0a0a0a;
padding: 0;
}
#xterm-container {
width: 100%;
height: 100%;
}
/* Hide the default terminal scrollbar (xterm handles it) */
#panel-terminal::-webkit-scrollbar { display: none; }
/* Sticky scroll header */
#sticky-scroll {
background: var(--bg3);
border-bottom: 1px solid var(--border);
font-family: 'DM Mono', monospace;
font-size: 11.5px;
color: var(--text2);
padding: 3px 12px;
flex-shrink: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
display: none;
opacity: 0.85;
}
#sticky-scroll { cursor: pointer; }
#sticky-scroll:hover { background: var(--bg2); }
#sticky-scroll .sticky-kind { color: var(--accent); margin-right: 4px; }
#sticky-scroll .sticky-name { color: var(--text); }
#sticky-scroll .sticky-goto { color: var(--text3); margin-left: 6px; font-size: 10px; }
#sticky-scroll.visible { display: block; }
/* Inline diff panel */
#panel-diff { padding: 0; }
.diff-toolbar {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
#diff-file-label { color: var(--text2); font-family: 'DM Mono', monospace; font-size: 11px; flex: 1; }
#diff-output {
flex: 1;
overflow-y: auto;
font-family: 'DM Mono', monospace;
font-size: 11px;
line-height: 1.5;
padding: 8px 12px;
}
.diff-line { display: block; white-space: pre; }
.diff-add { color: var(--green); background: rgba(74,222,128,0.05); }
.diff-del { color: var(--red); background: rgba(248,113,113,0.05); }
.diff-hunk { color: var(--accent); opacity: 0.7; }
.diff-meta { color: var(--text3); }
.diff-ctx { color: var(--text3); }
/* Tab keyboard shortcut hint */
.editor-tab[data-hotkey]::after {
content: attr(data-hotkey);
position: absolute;
top: 2px;
right: 14px;
font-size: 8px;
color: var(--text3);
display: none;
}
/* Outline panel */
.outline-item {
display: flex;
align-items: center;
gap: 6px;
padding: 3px 8px 3px 12px;
cursor: pointer;
font-family: 'DM Mono', monospace;
font-size: 11.5px;
color: var(--text2);
transition: background 0.1s;
border-radius: 2px;
}
.outline-item:hover { background: var(--bg3); color: var(--text); }
.outline-kind {
font-size: 9px;
font-weight: 700;
letter-spacing: 0.05em;
padding: 1px 4px;
border-radius: 3px;
flex-shrink: 0;
width: 36px;
text-align: center;
}
.outline-kind-fn { background: rgba(56,189,248,0.12); color: var(--accent); }
.outline-kind-type { background: rgba(167,139,250,0.12); color: var(--purple); }
.outline-kind-enum { background: rgba(167,139,250,0.12); color: var(--purple); }
.outline-kind-protocol { background: rgba(74,222,128,0.12); color: var(--green); }
.outline-kind-impl { background: rgba(251,191,36,0.10); color: var(--yellow); }
.outline-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.outline-line { color: var(--text3); font-size: 10px; flex-shrink: 0; }
/* Resizable bottom panel */
#bottom-resize-handle {
height: 4px;
background: transparent;
cursor: ns-resize;
flex-shrink: 0;
transition: background 0.15s;
}
#bottom-resize-handle:hover { background: var(--accent-dim); }
/* Vim mode indicator in status bar */
#status-vim-mode {
color: var(--orange);
font-weight: 700;
letter-spacing: 0.04em;
}
/* Clickable build error lines */
.out-line.error.clickable, .out-line.warning.clickable {
cursor: pointer;
text-decoration: underline dotted;
}
.out-line.error.clickable:hover { opacity: 0.85; }
/* 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; transition: background 0.15s; }
::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,0.18); }
::-webkit-scrollbar-corner { background: transparent; }
/* File tree file extension icons (via pseudo-element colors) */
.file-item[data-path$=".el"] .icon { color: var(--accent) !important; opacity: 1; }
.file-item[data-path$=".rs"] .icon { color: var(--orange) !important; opacity: 1; }
.file-item[data-path$=".toml"] .icon { color: var(--yellow) !important; opacity: 0.9; }
.file-item[data-path$=".json"] .icon { color: var(--green) !important; opacity: 0.9; }
.file-item[data-path$=".md"] .icon { color: var(--text2) !important; opacity: 0.9; }
.file-item[data-path$=".lock"] .icon { color: var(--text3) !important; opacity: 0.7; }
/* Notification toast */
#toast {
position: fixed;
bottom: 40px;
left: 50%;
transform: translateX(-50%) translateY(20px);
background: var(--bg4);
border: 1px solid var(--border2);
border-radius: 6px;
padding: 8px 18px;
font-family: 'DM Mono', monospace;
font-size: 12px;
color: var(--text);
z-index: 900;
pointer-events: none;
opacity: 0;
transition: opacity 0.2s, transform 0.2s;
box-shadow: 0 4px 20px rgba(0,0,0,0.4);
white-space: nowrap;
}
#toast.show {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
/* 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; }
/* ── CONTEXT MENU ───────────────────────────────────────────────────────────── */
#ctx-menu {
position: fixed;
z-index: 600;
background: var(--bg2);
border: 1px solid var(--border2);
border-radius: var(--radius);
box-shadow: 0 8px 24px rgba(0,0,0,0.5);
min-width: 140px;
overflow: hidden;
}
.ctx-item {
padding: 7px 14px;
font-size: 12px;
color: var(--text2);
cursor: pointer;
transition: background 0.1s;
}
.ctx-item:hover { background: var(--bg3); color: var(--text); }
.ctx-item.ctx-danger { color: var(--red); }
.ctx-item.ctx-danger:hover { background: rgba(248,113,113,0.08); }
.ctx-sep { height: 1px; background: var(--border); margin: 4px 0; }
/* ── QUICK OPEN MODAL ───────────────────────────────────────────────────────── */
#quick-open-modal {
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.5);
z-index: 700;
display: flex;
align-items: flex-start;
justify-content: center;
padding-top: 80px;
}
#quick-open-box {
background: var(--bg2);
border: 1px solid var(--border2);
border-radius: 8px;
box-shadow: 0 16px 48px rgba(0,0,0,0.6);
width: 500px;
max-width: 90vw;
overflow: hidden;
}
#quick-open-input {
width: 100%;
background: transparent;
border: none;
border-bottom: 1px solid var(--border);
color: var(--text);
font-family: 'DM Mono', monospace;
font-size: 14px;
padding: 14px 16px;
outline: none;
}
.qo-hint {
padding: 6px 16px;
font-size: 10px;
color: var(--text3);
border-bottom: 1px solid var(--border);
}
.qo-result .qo-icon {
font-size: 11px;
color: var(--text3);
flex-shrink: 0;
width: 16px;
}
#quick-open-results {
max-height: 320px;
overflow-y: auto;
}
.qo-result {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 16px;
cursor: pointer;
transition: background 0.1s;
font-family: 'DM Mono', monospace;
font-size: 12px;
}
.qo-result:hover, .qo-result.selected { background: var(--accent-dim); }
.qo-name { color: var(--text); flex: 1; }
.qo-path { color: var(--text3); font-size: 10px; }
.qo-mark { color: var(--accent); }
</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"></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 class="header-sep"></div>
<button class="btn" id="btn-neuron-toggle" title="Toggle Neuron pair programming panel">Neuron</button>
<div class="header-sep"></div>
<div class="theme-dropdown">
<button class="btn" id="btn-theme">Theme</button>
<div class="theme-menu" id="theme-menu" style="display:none">
<div class="theme-option" data-theme="dark">Dark</div>
<div class="theme-option" data-theme="light">Light</div>
<div class="theme-option" data-theme="neuron">Neuron</div>
<div class="theme-option" data-theme="high-contrast">High Contrast</div>
</div>
</div>
</div>
<!-- ═══ BREADCRUMB ═══════════════════════════════════════════════════════════ -->
<div id="breadcrumb"><span style="color:var(--text3)">no file open</span></div>
<!-- ═══ BODY ═════════════════════════════════════════════════════════════════ -->
<div id="body">
<div id="main-row">
<!-- ── FILE TREE ──────────────────────────────────────────────────────── -->
<div id="file-tree">
<div class="panel-header" style="display:flex;align-items:center;gap:4px;">
<span style="flex:1">Files</span>
<button class="graph-btn" id="btn-new-file" title="New file" style="width:18px;height:18px;font-size:13px;line-height:1;">+</button>
<button class="graph-btn" id="btn-refresh-tree" title="Refresh file tree" style="width:18px;height:18px;font-size:11px;"></button>
<button class="graph-btn" id="btn-collapse-tree" title="Collapse tree (Ctrl+B)" style="width:18px;height:18px;font-size:10px;"></button>
</div>
<div id="file-list">
<div style="color:var(--text3);padding:12px;font-size:11px;">Loading...</div>
</div>
</div>
<div class="panel-resize-handle" id="file-tree-resize"></div>
<!-- ── EDITOR ─────────────────────────────────────────────────────────── -->
<div id="editor-col">
<div id="editor-tabs">
<div id="editor-tabs-scroll"></div>
<div id="tab-overflow-btn" title="All tabs"></div>
<div id="tab-overflow-menu" style="display:none"></div>
</div>
<div id="sticky-scroll"></div>
<div id="editor-wrapper">
<div id="cm-editor"></div>
<div id="minimap-container">
<canvas id="minimap-canvas"></canvas>
<div id="minimap-viewport"></div>
</div>
</div>
<!-- Status bar sits between editor and bottom panel -->
<div id="status-bar">
<span id="status-file">no file</span>
<span class="status-sep">|</span>
<span id="status-pos">Ln 1, Col 1</span>
<span class="status-sep">|</span>
<span id="status-sel" style="display:none"></span>
<span id="status-sel-sep" class="status-sep" style="display:none">|</span>
<span id="status-lang">engram</span>
<div class="status-spacer"></div>
<span id="status-lines" style="color:var(--text3)">0 lines</span>
<span class="status-sep">|</span>
<span id="status-encoding">UTF-8</span>
<span class="status-sep">|</span>
<span id="status-vim-mode" style="display:none"></span>
<span id="status-el-ver" style="color:var(--text3);cursor:default" title="El compiler version"></span>
<span id="status-el-ver-sep" class="status-sep" style="display:none">|</span>
<span id="status-errors" style="display:none;color:var(--red);cursor:pointer" title="Errors — click to open Problems" onclick="switchBottomTab('problems')"></span>
<span id="status-warnings" style="display:none;color:var(--yellow);cursor:pointer" title="Warnings — click to open Problems" onclick="switchBottomTab('problems')"></span>
<span id="status-server">● connected</span>
</div>
</div>
<!-- ── TYPE GRAPH ─────────────────────────────────────────────────────── -->
<div id="type-graph">
<div class="panel-header">Type Graph</div>
<div id="graph-controls">
<button class="graph-btn" id="graph-zoom-in" title="Zoom in">+</button>
<button class="graph-btn" id="graph-zoom-out" title="Zoom out"></button>
<button class="graph-btn" id="graph-reset" title="Reset view"></button>
<span class="graph-zoom-label" id="graph-zoom-label">100%</span>
</div>
<canvas id="graph-canvas"></canvas>
<div id="graph-node-detail"></div>
<div id="graph-legend">
<div class="legend-item">
<div class="legend-dot" style="background:#4a5a6a"></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:#3a4a5a"></div>
<span>primitive</span>
</div>
<div class="legend-item">
<div class="legend-dot" style="background:#4ade80"></div>
<span>protocol</span>
</div>
<div class="legend-item">
<div class="legend-dot" style="background:#c084fc;box-shadow:0 0 6px rgba(192,132,252,0.5)"></div>
<span>activated</span>
</div>
</div>
</div>
<!-- ── NEURON PAIR PROGRAMMING PANEL ────────────────────────────────── -->
<div id="neuron-panel" style="display:none">
<div class="neuron-panel-header">
<div class="neuron-status-dot" id="neuron-status-dot"></div>
<div class="neuron-panel-title">Neuron</div>
<div class="neuron-panel-status" id="neuron-panel-status">disconnected</div>
</div>
<div id="neuron-messages">
<div class="neuron-msg">
<div class="neuron-msg-label neuron-label">Neuron</div>
<div class="neuron-msg-neuron">Hello. I'm Neuron — your pair programmer for Engram.
I can see your current file, understand the type graph, and help you write code that leverages the knowledge graph.
Try: "explain this selection", "suggest an activate query", or ask me anything about your code.</div>
</div>
</div>
<div id="neuron-input-row">
<div class="neuron-quick-actions">
<button class="neuron-quick-btn" id="nq-explain">Explain selection</button>
<button class="neuron-quick-btn" id="nq-activate">Find activations</button>
<button class="neuron-quick-btn" id="nq-types">Analyze types</button>
<button class="neuron-quick-btn" id="nq-clear">Clear</button>
</div>
<textarea id="neuron-input" placeholder="Ask Neuron anything about your code..." rows="2"></textarea>
<button id="neuron-send">Send</button>
</div>
</div>
</div><!-- /#main-row -->
<!-- Activation preview floating widget (positioned by JS) -->
<div id="activation-preview">
<div class="ap-header">
<span class="ap-icon"></span>
<span class="ap-count" id="ap-count">0</span>
<span style="color:var(--text3);font-size:10px;">nodes for</span>
<span class="ap-type" id="ap-type-name"></span>
</div>
<div class="ap-query" id="ap-query-text"></div>
<div class="ap-nodes" id="ap-nodes-list"></div>
</div>
<!-- ═══ BOTTOM PANEL ═════════════════════════════════════════════════════ -->
<div id="bottom-resize-handle"></div>
<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="search">Search</div>
<div class="bottom-tab" data-panel="terminal">Terminal</div>
<div class="bottom-tab" data-panel="outline">Outline</div>
<div class="bottom-tab" data-panel="diff">Git Diff</div>
<div class="bottom-tab" data-panel="reasoning">Reasoning</div>
<div class="bottom-tab" data-panel="knowledge">Knowledge Graph</div>
<div class="bottom-tab" data-panel="plugins">Plugins</div>
<div class="bottom-tab" data-panel="settings">Settings</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>
<!-- Search -->
<div id="panel-search" class="bottom-panel-content">
<div class="search-input-row">
<input id="search-input" type="text" placeholder="Search in project...">
<button class="search-toggle-btn" id="search-toggle-case" title="Match case (Alt+C)" style="font-size:11px;padding:0 6px;">Aa</button>
<button class="search-toggle-btn" id="search-toggle-word" title="Match whole word (Alt+W)" style="font-size:11px;padding:0 6px;">ab</button>
<button class="search-toggle-btn" id="search-toggle-regex" title="Use regex (Alt+R)" style="font-size:11px;padding:0 6px;">.*</button>
<input id="search-replace-input" type="text" placeholder="Replace..." style="flex:0.7">
<button class="btn btn-primary" id="btn-search">Search</button>
<button class="btn" id="btn-replace-all" title="Replace all">Replace all</button>
</div>
<div id="search-results"></div>
</div>
<!-- Terminal -->
<div id="panel-terminal" class="bottom-panel-content">
<div id="xterm-container"></div>
</div>
<!-- Git Diff -->
<div id="panel-diff" class="bottom-panel-content">
<div class="diff-toolbar">
<span id="diff-file-label">No file selected</span>
<button class="btn btn-primary" id="btn-diff-refresh" style="font-size:10px;padding:3px 8px">Refresh</button>
</div>
<div id="diff-output"><span style="color:var(--text3);font-size:11px;">Select a file and click Refresh to see its diff.</span></div>
</div>
<!-- Outline -->
<div id="panel-outline" class="bottom-panel-content">
<div id="outline-list"><div style="color:var(--text3);padding:8px 0;font-size:11px;">Open a file to see its outline.</div></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>
<!-- Knowledge Graph Explorer -->
<div id="panel-knowledge" class="bottom-panel-content">
<div class="kg-search-row">
<input id="kg-search-input" type="text" placeholder="Search knowledge graph concepts...">
<button class="btn btn-primary" id="btn-kg-search" style="white-space:nowrap">Search</button>
</div>
<div id="kg-results"><div class="kg-status-msg">Search the Engram knowledge graph to discover available data concepts.</div></div>
</div>
<!-- Plugins -->
<div id="panel-plugins" class="bottom-panel-content">
<div id="plugin-list">Loading...</div>
</div>
<!-- Settings -->
<div id="panel-settings" class="bottom-panel-content">
<div class="settings-section">
<div class="settings-title">Editor</div>
<div class="settings-row">
<label>Font size</label>
<input type="range" min="10" max="22" value="13" id="setting-font-size">
<span class="settings-val" id="setting-font-size-val">13px</span>
</div>
<div class="settings-row">
<label>Tab size</label>
<select id="setting-tab-size">
<option>2</option><option selected>4</option><option>8</option>
</select>
</div>
<div class="settings-row">
<label>Word wrap</label>
<input type="checkbox" id="setting-word-wrap">
</div>
<div class="settings-row">
<label>Vim keybindings</label>
<input type="checkbox" id="setting-vim-mode">
</div>
<div class="settings-row">
<label>Format on save</label>
<input type="checkbox" id="setting-format-on-save">
</div>
</div>
<div class="settings-section">
<div class="settings-title">Theme</div>
<div class="settings-theme-grid" id="settings-theme-grid">
<button class="settings-theme-btn active" data-theme="dark">Dark</button>
<button class="settings-theme-btn" data-theme="light">Light</button>
<button class="settings-theme-btn" data-theme="neuron">Neuron</button>
<button class="settings-theme-btn" data-theme="high-contrast">High Contrast</button>
</div>
</div>
<div class="settings-section">
<div class="settings-title">Keyboard Shortcuts</div>
<table class="shortcuts-table">
<tr><td>Save file</td><td><kbd>Cmd</kbd> + <kbd>S</kbd></td></tr>
<tr><td>Close tab</td><td><kbd>Cmd</kbd> + <kbd>W</kbd> or middle-click</td></tr>
<tr><td>Cycle tabs</td><td><kbd>Cmd</kbd> + <kbd>Tab</kbd></td></tr>
<tr><td>Switch to tab 1-9</td><td><kbd>Cmd</kbd> + <kbd>1</kbd><kbd>9</kbd></td></tr>
<tr><td>Find / Replace</td><td><kbd>Cmd</kbd> + <kbd>F</kbd> / <kbd>H</kbd></td></tr>
<tr><td>Search project</td><td><kbd>Cmd</kbd> + <kbd>Shift</kbd> + <kbd>F</kbd></td></tr>
<tr><td>Quick open files</td><td><kbd>Cmd</kbd> + <kbd>P</kbd></td></tr>
<tr><td>Command palette</td><td><kbd>Cmd</kbd> + <kbd>Shift</kbd> + <kbd>P</kbd> or <kbd>&gt;</kbd> in open</td></tr>
<tr><td>Go to definition</td><td><kbd>F12</kbd></td></tr>
<tr><td>Find references</td><td><kbd>Shift</kbd> + <kbd>F12</kbd></td></tr>
<tr><td>Build</td><td><kbd>Cmd</kbd> + <kbd>B</kbd></td></tr>
<tr><td>Fold / Unfold</td><td><kbd>Cmd</kbd> + <kbd>[</kbd> / <kbd>]</kbd></td></tr>
<tr><td>Close menu / modal</td><td><kbd>Esc</kbd></td></tr>
</table>
</div>
</div>
</div><!-- /#bottom-content -->
</div>
</div><!-- /#body -->
<!-- ── CONTEXT MENU ──────────────────────────────────────────────────────────── -->
<div id="ctx-menu" style="display:none">
<div class="ctx-item" id="ctx-new-file">New File</div>
<div class="ctx-item" id="ctx-new-folder">New Folder</div>
<div class="ctx-sep"></div>
<div class="ctx-item" id="ctx-rename">Rename</div>
<div class="ctx-item ctx-danger" id="ctx-delete">Delete</div>
</div>
<!-- ── QUICK OPEN MODAL ──────────────────────────────────────────────────────── -->
<div id="quick-open-modal" style="display:none">
<div id="quick-open-box">
<input id="quick-open-input" placeholder="Open file…" autocomplete="off">
<div id="quick-open-results"></div>
</div>
</div>
<!-- Hover tooltip -->
<div class="tooltip" id="tooltip" style="display:none"></div>
<!-- Toast notification -->
<div id="toast"></div>
<script type="module">
// ═══════════════════════════════════════════════════════════════════════════
// Imports — CodeMirror 6 via ESM CDN
// ═══════════════════════════════════════════════════════════════════════════
import { EditorView, basicSetup, highlightActiveLine } from 'https://esm.sh/codemirror@6.0.1';
import { keymap, drawSelection } from 'https://esm.sh/@codemirror/view@6';
import { indentWithTab, history, historyKeymap, defaultKeymap } from 'https://esm.sh/@codemirror/commands@6';
import { StreamLanguage, foldGutter, codeFolding, foldKeymap } from 'https://esm.sh/@codemirror/language@6';
import { linter, lintGutter } from 'https://esm.sh/@codemirror/lint@6';
import { EditorState, EditorSelection, StateEffect, StateField, RangeSetBuilder, Transaction } from 'https://esm.sh/@codemirror/state@6';
// Expose EditorSelection for selectNextOccurrence helper
window._cm6State = { EditorSelection };
import { Decoration, MatchDecorator, ViewPlugin } from 'https://esm.sh/@codemirror/view@6';
import { autocompletion, completionKeymap, snippetCompletion } from 'https://esm.sh/@codemirror/autocomplete@6';
import { searchKeymap, search, SearchQuery, setSearchQuery, openSearchPanel, closeSearchPanel } from 'https://esm.sh/@codemirror/search@6';
import { closeBrackets, closeBracketsKeymap } from 'https://esm.sh/@codemirror/autocomplete@6';
// Lazily loaded — xterm.js (loaded on first Terminal tab open)
let xtermLoaded = false;
let xterm = null;
let xtermWs = null;
// Vim mode state
let vimEnabled = false;
let vimExtension = null;
// ═══════════════════════════════════════════════════════════════════════════
// Engram-lang StreamLanguage definition for CodeMirror 6
// ═══════════════════════════════════════════════════════════════════════════
const KEYWORDS2 = new Set(['activate', 'sealed', 'parallel', 'deploy']);
const KEYWORDS = new Set([
'let','fn','type','enum','match','return','where','if','else','for','in','while',
'true','false','protocol','impl','import','from','as','with','retry','times',
'fallback','reason','trace','requires','to','via','test','seed','assert','target',
]);
const BUILTINS = new Set(['Int','Float','String','Bool','Uuid','Void','List','Map','Option','Result','Unit']);
const engramLang = StreamLanguage.define({
name: 'engram',
token(stream) {
if (stream.eatSpace()) return null;
if (stream.match('//')) { stream.skipToEnd(); return 'comment'; }
if (stream.eat('"')) {
while (!stream.eol()) {
if (stream.eat('\\')) { stream.next(); continue; }
if (stream.eat('"')) break;
stream.next();
}
return 'string';
}
if (stream.match(/^-?\d+(\.\d+)?/)) return 'number';
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';
if (/^[A-Z]/.test(word)) return 'type';
// Function call: identifier followed by (
if (stream.peek() === '(') return 'function';
return 'variable';
}
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: '//' } },
});
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' },
'.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)' },
'.tok-function': { color: 'var(--yellow)' },
'.tok-def': { color: 'var(--accent)', fontWeight: '600' },
'.tok-atom': { color: 'var(--orange)' },
}, { dark: true });
}
// ═══════════════════════════════════════════════════════════════════════════
// Engram-lang Snippets
// ═══════════════════════════════════════════════════════════════════════════
const ENGRAM_SNIPPETS = [
snippetCompletion('fn ${name}(${params}) -> ${ReturnType} {\n\t${body}\n}', { label: 'fn', detail: 'function', type: 'keyword' }),
snippetCompletion('type ${Name} {\n\t${field}: ${Type},\n}', { label: 'type', detail: 'struct type', type: 'keyword' }),
snippetCompletion('enum ${Name} {\n\t${Variant},\n}', { label: 'enum', detail: 'enum type', type: 'keyword' }),
snippetCompletion('match ${expr} {\n\t${pattern} => ${body},\n}', { label: 'match', detail: 'match expression', type: 'keyword' }),
snippetCompletion('let ${name}: ${Type} = ${value};', { label: 'let', detail: 'binding', type: 'keyword' }),
snippetCompletion('protocol ${Name} {\n\tfn ${method}(${self}) -> ${Ret};\n}', { label: 'protocol', detail: 'protocol def', type: 'keyword' }),
snippetCompletion('impl ${Protocol} for ${Type} {\n\t${body}\n}', { label: 'impl', detail: 'impl block', type: 'keyword' }),
snippetCompletion('if ${cond} {\n\t${body}\n}', { label: 'if', detail: 'if expression', type: 'keyword' }),
snippetCompletion('for ${item} in ${iter} {\n\t${body}\n}', { label: 'for', detail: 'for loop', type: 'keyword' }),
snippetCompletion('test "${description}" {\n\t${body}\n}', { label: 'test', detail: 'test block', type: 'keyword' }),
snippetCompletion('seed {\n\t${body}\n}', { label: 'seed', detail: 'seed block', type: 'keyword' }),
snippetCompletion('assert ${expr};', { label: 'assert', detail: 'assertion', type: 'keyword' }),
snippetCompletion('activate ${TypeName} where "${query}"', { label: 'activate', detail: 'activate query', type: 'keyword' }),
snippetCompletion('parallel {\n\t${task1},\n\t${task2},\n}', { label: 'parallel', detail: 'parallel block', type: 'keyword' }),
snippetCompletion('deploy ${service} to ${target} via ${method};', { label: 'deploy', detail: 'deploy statement', type: 'keyword' }),
snippetCompletion('import ${symbol} from "${module}";', { label: 'import', detail: 'import statement', type: 'keyword' }),
snippetCompletion('with ${resource} as ${name} {\n\t${body}\n}', { label: 'with', detail: 'with block', type: 'keyword' }),
snippetCompletion('retry ${times} times {\n\t${body}\n} fallback {\n\t${fallback}\n}', { label: 'retry', detail: 'retry/fallback', type: 'keyword' }),
snippetCompletion('reason "${goal}" {\n\t${context}\n}', { label: 'reason', detail: 'reasoning block', type: 'keyword' }),
snippetCompletion('trace "${label}" {\n\t${body}\n}', { label: 'trace', detail: 'trace block', type: 'keyword' }),
];
function engramSnippetSource(context) {
const word = context.matchBefore(/\w*/);
if (!word || (word.from === word.to && !context.explicit)) return null;
const q = word.text.toLowerCase();
const matches = ENGRAM_SNIPPETS.filter(s => s.label.startsWith(q));
if (!matches.length) return null;
return { from: word.from, options: matches };
}
// ═══════════════════════════════════════════════════════════════════════════
// Word highlight — highlights all occurrences of the word under cursor
// ═══════════════════════════════════════════════════════════════════════════
const wordHighlightMark = Decoration.mark({ class: 'cm-wordHighlight' });
const wordHighlightPlugin = ViewPlugin.fromClass(class {
decorations;
constructor(view) { this.decorations = this.buildDecorations(view); }
update(update) {
if (update.selectionSet || update.docChanged) {
this.decorations = this.buildDecorations(update.view);
}
}
buildDecorations(view) {
const sel = view.state.selection.main;
// Only highlight when cursor is inside a word and no explicit selection
if (!sel.empty) return Decoration.none;
const doc = view.state.doc;
const pos = sel.head;
const line = doc.lineAt(pos);
const text = line.text;
const linePos = pos - line.from;
// Extract word at cursor
let start = linePos, end = linePos;
while (start > 0 && /\w/.test(text[start - 1])) start--;
while (end < text.length && /\w/.test(text[end])) end++;
const word = text.slice(start, end);
if (!word || word.length < 2) return Decoration.none;
const builder = new RangeSetBuilder();
const fullText = doc.toString();
const regex = new RegExp(`\\b${word.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`, 'g');
let match;
while ((match = regex.exec(fullText)) !== null) {
const from = match.index, to = from + word.length;
// Don't highlight the cursor word itself (it's already highlighted by activeLine)
if (from <= pos && to >= pos) continue;
builder.add(from, to, wordHighlightMark);
}
return builder.finish();
}
}, { decorations: v => v.decorations });
// ═══════════════════════════════════════════════════════════════════════════
// Server-side Snippets (from /api/completions/snippet)
// ═══════════════════════════════════════════════════════════════════════════
let serverSnippets = null;
async function loadServerSnippets() {
try {
const resp = await fetch('/api/completions/snippet');
if (resp.ok) {
const data = await resp.json();
serverSnippets = data.map(s =>
snippetCompletion(s.body.replace(/\$\{(\d+):([^}]+)\}/g, '${$2}'), {
label: s.label,
detail: s.description,
type: 'keyword',
})
);
}
} catch {}
}
function serverSnippetSource(context) {
if (!serverSnippets) return null;
const word = context.matchBefore(/\w*/);
if (!word || (word.from === word.to && !context.explicit)) return null;
const q = word.text.toLowerCase();
const matches = serverSnippets.filter(s => s.label.startsWith(q));
if (!matches.length) return null;
return { from: word.from, options: matches };
}
// ═══════════════════════════════════════════════════════════════════════════
// LSP Completions
// ═══════════════════════════════════════════════════════════════════════════
async function elComplete(context) {
// Trigger on word chars or explicit invocation
const word = context.matchBefore(/\w*/);
if (!word && !context.explicit) return null;
const source = context.state.doc.toString();
const pos = context.pos;
try {
const resp = await fetch(`/api/lsp/complete?source=${encodeURIComponent(source)}&pos=${pos}`);
if (!resp.ok) return null;
const completions = await resp.json();
if (!completions || !completions.length) return null;
// Detect if cursor is in an activate...where "..." context for semantic hint
const lineText = context.state.doc.lineAt(pos).text;
const inActivateStr = /activate\s+\w+\s+where\s+"[^"]*/.test(lineText.slice(0, pos - context.state.doc.lineAt(pos).from));
return {
from: word ? word.from : pos,
options: completions.map(c => {
const isSemantic = (c.score || 0) >= 0.8 || c.kind === 'keyword2';
return {
label: c.label,
type: c.kind === 'keyword' || c.kind === 'keyword2' ? 'keyword' : c.kind === 'type' ? 'type' : 'variable',
detail: isSemantic
? `⟁ semantic ${c.detail || ''}`
: c.detail || '',
info: c.documentation || '',
boost: isSemantic ? 10 : 0,
};
}),
};
} catch { return null; }
}
// ═══════════════════════════════════════════════════════════════════════════
// IDE State — Tabs
// ═══════════════════════════════════════════════════════════════════════════
let tabs = []; // [{id, path, name, content, unsaved}]
let activeTabId = null;
let nextTabId = 1;
function openTab(path, name, content) {
// Check if already open
const existing = tabs.find(t => t.path === path);
if (existing) {
switchTab(existing.id);
return;
}
const id = nextTabId++;
tabs.push({ id, path, name, content, unsaved: false });
activeTabId = id;
renderTabs();
loadTabIntoEditor(id);
updateBreadcrumb(path);
}
function closeTab(id) {
const tab = tabs.find(t => t.id === id);
if (!tab) return;
if (tab.unsaved) {
if (!confirm(`"${tab.name}" has unsaved changes. Close anyway?`)) return;
}
const idx = tabs.indexOf(tab);
tabs = tabs.filter(t => t.id !== id);
if (activeTabId === id) {
// Switch to adjacent tab
if (tabs.length > 0) {
const newIdx = Math.min(idx, tabs.length - 1);
switchTab(tabs[newIdx].id);
} else {
activeTabId = null;
setEditorContent('');
updateBreadcrumb(null);
updateStatusFile(null);
}
}
renderTabs();
}
function switchTab(id) {
// Save current editor content to current tab before switching
if (activeTabId !== null) {
const current = tabs.find(t => t.id === activeTabId);
if (current) {
current.content = editor.state.doc.toString();
}
}
activeTabId = id;
renderTabs();
loadTabIntoEditor(id);
const tab = tabs.find(t => t.id === id);
if (tab) {
updateBreadcrumb(tab.path);
updateStatusFile(tab.path);
highlightFileInTree(tab.path);
}
}
function renderTabs() {
const container = document.getElementById('editor-tabs-scroll');
container.innerHTML = '';
for (const tab of tabs) {
const el = document.createElement('div');
el.className = 'editor-tab' + (tab.id === activeTabId ? ' active' : '') + (tab.unsaved ? ' unsaved' : '');
el.dataset.tabId = tab.id;
const dot = document.createElement('div');
dot.className = 'dot';
const nameSpan = document.createElement('span');
nameSpan.textContent = tab.name;
const close = document.createElement('div');
close.className = 'tab-close';
close.textContent = '×';
close.title = 'Close (middle-click)';
close.addEventListener('click', e => { e.stopPropagation(); closeTab(tab.id); });
el.appendChild(dot);
el.appendChild(nameSpan);
el.appendChild(close);
el.addEventListener('click', () => switchTab(tab.id));
// Scroll active tab into view
if (tab.id === activeTabId) {
setTimeout(() => el.scrollIntoView({ block: 'nearest', inline: 'nearest' }), 10);
}
container.appendChild(el);
}
renderTabOverflow();
}
function renderTabOverflow() {
const menu = document.getElementById('tab-overflow-menu');
menu.innerHTML = tabs.map(tab =>
`<div class="tab-overflow-item${tab.id === activeTabId ? ' active-tab' : ''}" data-tab-id="${tab.id}">
${tab.unsaved ? '<span style="color:var(--orange);margin-right:2px">●</span>' : ''}
${escapeHtml(tab.name)}
</div>`
).join('') || '<div style="color:var(--text3);padding:8px 14px;font-size:11px;">No open tabs</div>';
menu.querySelectorAll('.tab-overflow-item[data-tab-id]').forEach(el => {
el.addEventListener('click', () => {
switchTab(parseInt(el.dataset.tabId, 10));
menu.style.display = 'none';
});
});
}
// Tab overflow toggle
const tabOverflowBtn = document.getElementById('tab-overflow-btn');
const tabOverflowMenu = document.getElementById('tab-overflow-menu');
tabOverflowBtn.addEventListener('click', e => {
e.stopPropagation();
renderTabOverflow();
tabOverflowMenu.style.display = tabOverflowMenu.style.display === 'none' ? 'block' : 'none';
});
document.addEventListener('click', () => {
tabOverflowMenu.style.display = 'none';
});
function loadTabIntoEditor(id) {
const tab = tabs.find(t => t.id === id);
if (!tab) return;
setEditorContent(tab.content);
// Restore unsaved state visual
const tabEl = document.querySelector(`[data-tab-id="${id}"]`);
if (tabEl) tabEl.classList.toggle('unsaved', tab.unsaved);
}
function markActiveTabUnsaved(unsaved) {
const tab = tabs.find(t => t.id === activeTabId);
if (!tab) return;
tab.unsaved = unsaved;
const tabEl = document.querySelector(`[data-tab-id="${tab.id}"]`);
if (tabEl) tabEl.classList.toggle('unsaved', unsaved);
}
// ═══════════════════════════════════════════════════════════════════════════
// Breadcrumb
// ═══════════════════════════════════════════════════════════════════════════
function updateBreadcrumb(path) {
const bc = document.getElementById('breadcrumb');
if (!path) {
bc.innerHTML = '<span style="color:var(--text3)">no file open</span>';
return;
}
const parts = path.split('/');
bc.innerHTML = parts.map((part, i) => {
if (i === parts.length - 1) {
return `<span class="crumb-current">${escapeHtml(part)}</span>`;
}
const dirPath = parts.slice(0, i + 1).join('/');
return `<span class="crumb" data-path="${escapeHtml(dirPath)}">${escapeHtml(part)}</span><span class="crumb-sep"> / </span>`;
}).join('');
}
// ═══════════════════════════════════════════════════════════════════════════
// 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();
updateProblemsPanel(diags);
return diags.map(d => {
let from = 0;
let to = Math.min(source.length, 1);
// Use line/col if available for accurate underlines
if (d.line) {
try {
const lineInfo = view.state.doc.line(d.line);
const col = Math.max(0, (d.col || 1) - 1);
from = lineInfo.from + col;
// Underline to end of word or end of line
const lineText = lineInfo.text;
let endCol = col;
while (endCol < lineText.length && /\w/.test(lineText[endCol])) endCol++;
to = lineInfo.from + Math.max(endCol, col + 1);
} catch {}
}
return {
from,
to,
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,
...completionKeymap,
...closeBracketsKeymap,
...foldKeymap,
...searchKeymap,
]),
engramLang,
engramTheme(),
lspLinter,
lintGutter(),
foldGutter(),
codeFolding(),
closeBrackets(),
search({ top: false }),
autocompletion({ override: [elComplete, engramSnippetSource, serverSnippetSource] }),
wordHighlightPlugin,
EditorView.updateListener.of(update => {
if (update.docChanged) {
markActiveTabUnsaved(true);
// Update current tab content cache
const tab = tabs.find(t => t.id === activeTabId);
if (tab) tab.content = update.state.doc.toString();
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
const src = update.state.doc.toString();
refreshTypeGraph(src);
updateActivationHighlights(src);
// Refresh outline if panel visible
const outlinePanel = document.getElementById('panel-outline');
if (outlinePanel && outlinePanel.classList.contains('active')) {
refreshOutline();
}
}, 800);
}
// Activation preview — debounced on cursor/doc change
if (update.selectionSet || update.docChanged) {
clearTimeout(activationPreviewTimer);
activationPreviewTimer = setTimeout(() => {
updateActivationPreview(update.view);
}, 300);
}
// Update cursor position + selection in status bar
if (update.selectionSet || update.docChanged) {
const sel = update.state.selection.main;
const line = update.state.doc.lineAt(sel.head);
const col = sel.head - line.from + 1;
document.getElementById('status-pos').textContent = `Ln ${line.number}, Col ${col}`;
// Selection info
const selEl = document.getElementById('status-sel');
const selSep = document.getElementById('status-sel-sep');
if (!sel.empty) {
const selLen = sel.to - sel.from;
const selLines = update.state.doc.lineAt(sel.to).number - update.state.doc.lineAt(sel.from).number + 1;
selEl.textContent = selLines > 1 ? `${selLen} chars (${selLines} lines)` : `${selLen} chars`;
selEl.style.display = '';
selSep.style.display = '';
} else {
selEl.style.display = 'none';
selSep.style.display = 'none';
}
if (update.docChanged) {
document.getElementById('status-lines').textContent = `${update.state.doc.lines} lines`;
}
}
}),
EditorView.domEventHandlers({
keydown(e) {
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
e.preventDefault();
saveCurrentFile();
return true;
}
if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key === 'F') {
e.preventDefault();
switchBottomTab('search');
document.getElementById('search-input').focus();
return true;
}
if ((e.ctrlKey || e.metaKey) && e.key === 'b') {
e.preventDefault();
runBuildOrRun('build');
return true;
}
if ((e.ctrlKey || e.metaKey) && e.key === 'p') {
e.preventDefault();
showQuickOpen();
return true;
}
if ((e.ctrlKey || e.metaKey) && e.key === 'h') {
e.preventDefault();
openSearchPanel(editor);
return true;
}
if ((e.ctrlKey || e.metaKey) && e.key === 'f') {
e.preventDefault();
openSearchPanel(editor);
return true;
}
if ((e.ctrlKey || e.metaKey) && e.key === 'g') {
e.preventDefault();
goToLine();
return true;
}
if ((e.ctrlKey || e.metaKey) && e.key === 'r') {
e.preventDefault();
runBuildOrRun('run');
return true;
}
if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key === 'K') {
// Delete current line
e.preventDefault();
const sel = editor.state.selection.main;
const line = editor.state.doc.lineAt(sel.head);
const from = line.from;
const to = line.to < editor.state.doc.length ? line.to + 1 : line.to;
editor.dispatch({ changes: { from, to, insert: '' } });
return true;
}
if ((e.ctrlKey || e.metaKey) && e.key === '/') {
// Toggle line comment
e.preventDefault();
toggleLineComment();
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(); },
blur() { activationPreview.classList.remove('visible'); },
}),
],
});
function setEditorContent(text) {
editor.dispatch({
changes: { from: 0, to: editor.state.doc.length, insert: text },
});
}
function updateStatusFile(path) {
document.getElementById('status-file').textContent = path ? path.split('/').pop() : 'no file';
// Update language indicator
const ext = path ? path.split('.').pop().toLowerCase() : '';
const lang = ext === 'el' ? 'engram' : ext === 'rs' ? 'rust' : ext === 'toml' ? 'toml' : ext === 'json' ? 'json' : ext === 'md' ? 'markdown' : ext || 'text';
document.getElementById('status-lang').textContent = lang;
// Update line count
document.getElementById('status-lines').textContent = `${editor.state.doc.lines} lines`;
}
// ═══════════════════════════════════════════════════════════════════════════
// 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 (with flat index for quick-open)
// ═══════════════════════════════════════════════════════════════════════════
let allFiles = []; // flat list of {path, name} for quick-open
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();
allFiles = [];
collectFiles(entries);
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 collectFiles(entries) {
for (const e of entries) {
if (!e.is_dir) allFiles.push({ path: e.path, name: e.name });
if (e.children) collectFiles(e.children);
}
}
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 ? '▸' : '▾';
const childContainer = item.nextElementSibling;
if (childContainer && childContainer.dataset.children) {
childContainer.style.display = isOpen ? 'none' : '';
}
});
list.appendChild(item);
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', () => openFileFromTree(entry.path, entry.name));
item.addEventListener('contextmenu', e => {
e.preventDefault();
showCtxMenu(e.clientX, e.clientY, entry.path, false);
});
list.appendChild(item);
}
if (entry.is_dir) {
item.addEventListener('contextmenu', e => {
e.preventDefault();
showCtxMenu(e.clientX, e.clientY, entry.path, true);
});
}
}
}
function highlightFileInTree(path) {
document.querySelectorAll('.file-item').forEach(el => el.classList.remove('active'));
document.querySelector(`.file-item[data-path="${CSS.escape(path)}"]`)?.classList.add('active');
}
async function openFileFromTree(path, name) {
highlightFileInTree(path);
// Check if already in tabs
const existing = tabs.find(t => t.path === path);
if (existing) {
switchTab(existing.id);
return;
}
try {
const resp = await fetch(`/api/file?path=${encodeURIComponent(path)}`);
if (!resp.ok) throw new Error(await resp.text());
const { content } = await resp.json();
openTab(path, name, content);
refreshTypeGraph(content);
updateActivationHighlights(content);
} catch (err) {
appendBuildLine('error', `Failed to open ${path}: ${err}`);
switchBottomTab('build');
}
}
async function saveCurrentFile() {
const tab = tabs.find(t => t.id === activeTabId);
if (!tab) return;
// Format on save if enabled
if (formatOnSave) await formatCurrentFile();
const content = editor.state.doc.toString();
tab.content = content;
try {
const resp = await fetch('/api/file', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: tab.path, content }),
});
if (!resp.ok) throw new Error(await resp.text());
markActiveTabUnsaved(false);
showToast(`Saved ${tab.path.split('/').pop()}`);
} catch (err) {
appendBuildLine('error', `Save failed: ${err}`);
}
}
// ═══════════════════════════════════════════════════════════════════════════
// Context Menu
// ═══════════════════════════════════════════════════════════════════════════
const ctxMenu = document.getElementById('ctx-menu');
let ctxTargetPath = null;
let ctxTargetIsDir = false;
function showCtxMenu(x, y, path, isDir) {
ctxTargetPath = path;
ctxTargetIsDir = isDir;
ctxMenu.style.display = 'block';
ctxMenu.style.left = x + 'px';
ctxMenu.style.top = y + 'px';
// Adjust if off-screen
const rect = ctxMenu.getBoundingClientRect();
if (rect.right > window.innerWidth) ctxMenu.style.left = (window.innerWidth - rect.width - 4) + 'px';
if (rect.bottom > window.innerHeight) ctxMenu.style.top = (window.innerHeight - rect.height - 4) + 'px';
}
function hideCtxMenu() {
ctxMenu.style.display = 'none';
ctxTargetPath = null;
}
document.addEventListener('click', e => {
if (!ctxMenu.contains(e.target)) hideCtxMenu();
});
document.addEventListener('keydown', e => {
if (e.key === 'Escape') {
hideCtxMenu();
hideQuickOpen();
const themeMenu = document.getElementById('theme-menu');
if (themeMenu.style.display !== 'none') themeMenu.style.display = 'none';
}
});
document.getElementById('ctx-new-file').addEventListener('click', async () => {
if (!ctxTargetPath) return;
hideCtxMenu();
const name = prompt('New file name:');
if (!name) return;
const dir = ctxTargetIsDir ? ctxTargetPath : ctxTargetPath.split('/').slice(0, -1).join('/');
const newPath = dir ? `${dir}/${name}` : name;
try {
await fetch('/api/file', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: newPath, content: '' }),
});
await loadFileTree();
await openFileFromTree(newPath, name);
} catch (err) { alert(`Error: ${err}`); }
});
document.getElementById('ctx-new-folder').addEventListener('click', async () => {
if (!ctxTargetPath) return;
hideCtxMenu();
const name = prompt('New folder name:');
if (!name) return;
const dir = ctxTargetIsDir ? ctxTargetPath : ctxTargetPath.split('/').slice(0, -1).join('/');
const newPath = dir ? `${dir}/${name}` : name;
try {
await fetch('/api/files/mkdir', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: newPath }),
});
await loadFileTree();
} catch (err) { alert(`Error: ${err}`); }
});
document.getElementById('ctx-rename').addEventListener('click', async () => {
if (!ctxTargetPath) return;
const oldName = ctxTargetPath.split('/').pop();
hideCtxMenu();
const newName = prompt(`Rename "${oldName}" to:`, oldName);
if (!newName || newName === oldName) return;
const dir = ctxTargetPath.split('/').slice(0, -1).join('/');
const newPath = dir ? `${dir}/${newName}` : newName;
try {
await fetch('/api/files/rename', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ from: ctxTargetPath, to: newPath }),
});
// Update open tabs that referenced the old path
const oldPath = ctxTargetPath;
for (const tab of tabs) {
if (tab.path === oldPath) {
tab.path = newPath;
tab.name = newName;
}
}
renderTabs();
await loadFileTree();
} catch (err) { alert(`Error: ${err}`); }
});
document.getElementById('ctx-delete').addEventListener('click', async () => {
if (!ctxTargetPath) return;
const targetPath = ctxTargetPath;
hideCtxMenu();
if (!confirm(`Delete "${targetPath}"?`)) return;
try {
await fetch(`/api/file?path=${encodeURIComponent(targetPath)}`, { method: 'DELETE' });
// Close any tabs referencing deleted path
const toClose = tabs.filter(t => t.path === targetPath || t.path.startsWith(targetPath + '/')).map(t => t.id);
for (const id of toClose) {
tabs = tabs.filter(t => t.id !== id);
}
if (toClose.includes(activeTabId)) {
activeTabId = tabs.length > 0 ? tabs[tabs.length - 1].id : null;
if (activeTabId !== null) loadTabIntoEditor(activeTabId);
else { setEditorContent(''); updateBreadcrumb(null); updateStatusFile(null); }
}
renderTabs();
await loadFileTree();
} catch (err) { alert(`Error: ${err}`); }
});
// ═══════════════════════════════════════════════════════════════════════════
// Quick Open (Cmd+P)
// ═══════════════════════════════════════════════════════════════════════════
const quickOpenModal = document.getElementById('quick-open-modal');
const quickOpenInput = document.getElementById('quick-open-input');
const quickOpenResults = document.getElementById('quick-open-results');
let qoSelectedIdx = 0;
// IDE commands for command palette (> prefix)
const IDE_COMMANDS = [
{ label: 'Build Project', icon: '⚙', action: () => runBuildOrRun('build') },
{ label: 'Run Project', icon: '▶', action: () => runBuildOrRun('run') },
{ label: 'Check Errors', icon: '✓', action: () => document.getElementById('btn-check').click() },
{ label: 'Format File', icon: '⌘', action: () => formatCurrentFile() },
{ label: 'Open Search Panel', icon: '🔍', action: () => { switchBottomTab('search'); document.getElementById('search-input').focus(); } },
{ label: 'Open Terminal', icon: '>', action: () => switchBottomTab('terminal') },
{ label: 'Open Outline', icon: '≡', action: () => switchBottomTab('outline') },
{ label: 'Open Plugins', icon: '⚡', action: () => switchBottomTab('plugins') },
{ label: 'Open Settings', icon: '⚙', action: () => switchBottomTab('settings') },
{ label: 'Theme: Dark', icon: '◐', action: () => applyTheme('dark') },
{ label: 'Theme: Light', icon: '◑', action: () => applyTheme('light') },
{ label: 'Theme: Neuron', icon: '◈', action: () => applyTheme('neuron') },
{ label: 'Theme: High Contrast', icon: '◉', action: () => applyTheme('high-contrast') },
{ label: 'Reload File Tree', icon: '↺', action: () => loadFileTree().then(loadGitStatus) },
{ label: 'Find & Replace', icon: '⇄', action: () => openSearchPanel(editor) },
{ label: 'Save File', icon: '💾', action: () => saveCurrentFile() },
{ label: 'Close Active Tab', icon: '×', action: () => { if (activeTabId) closeTab(activeTabId); } },
{ label: 'Git Status Refresh', icon: '⎇', action: () => loadGitStatus() },
{ label: 'Open Neuron Panel', icon: '⟁', action: () => { if (!neuronPanelVisible) toggleNeuronPanel(); neuronInput.focus(); } },
{ label: 'Open Knowledge Graph', icon: '◈', action: () => { switchBottomTab('knowledge'); } },
{ label: 'Neuron: Explain File', icon: '⟁', action: () => sendNeuronMessage('Explain this file: what does it do and how is it structured?') },
{ label: 'Neuron: Suggest Activations', icon: '⟁', action: () => sendNeuronMessage('What activate queries would be useful for the types in this file?') },
{ label: 'Go To Line', icon: '→', action: () => goToLine() },
{ label: 'Toggle Word Wrap', icon: '↵', action: () => { wordWrapCheck.checked = !wordWrapCheck.checked; wordWrapCheck.dispatchEvent(new Event('change')); } },
{ label: 'Toggle Minimap', icon: '▦', action: () => { const mm = document.getElementById('minimap-container'); mm.style.display = mm.style.display === 'none' ? '' : 'none'; } },
{ label: 'Toggle File Tree', icon: '⊞', action: () => toggleFileTree() },
{ label: 'Toggle Bottom Panel', icon: '⬇', action: () => toggleBottomPanel() },
{ label: 'Increase Font Size', icon: 'A+', action: () => { const s = Math.min(24, parseInt(fontSizeSlider.value) + 1); fontSizeSlider.value = s; applySetting('fontSize', s); localStorage.setItem('el-ide-setting-fontSize', s); } },
{ label: 'Decrease Font Size', icon: 'A-', action: () => { const s = Math.max(10, parseInt(fontSizeSlider.value) - 1); fontSizeSlider.value = s; applySetting('fontSize', s); localStorage.setItem('el-ide-setting-fontSize', s); } },
{ label: 'Select Next Occurrence', icon: '▸▸', action: () => selectNextOccurrence() },
{ label: 'New File', icon: '+', action: () => { const name = prompt('New file name:'); if (name) createNewFile(name); } },
];
function fuzzyScore(query, text) {
const q = query.toLowerCase();
const t = text.toLowerCase();
if (t.includes(q)) return 2;
// Simple fuzzy: all query chars present in order
let qi = 0;
for (let i = 0; i < t.length && qi < q.length; i++) {
if (t[i] === q[qi]) qi++;
}
return qi === q.length ? 1 : 0;
}
function showQuickOpen() {
quickOpenModal.style.display = 'flex';
quickOpenInput.value = '';
qoSelectedIdx = 0;
renderQuickOpenResults('');
quickOpenInput.focus();
}
function hideQuickOpen() {
quickOpenModal.style.display = 'none';
}
function renderQuickOpenResults(query) {
// Command mode: starts with >
if (query.startsWith('>')) {
const q = query.slice(1).trim().toLowerCase();
const filtered = q
? IDE_COMMANDS.filter(c => fuzzyScore(q, c.label) > 0)
.sort((a, b) => fuzzyScore(q, b.label) - fuzzyScore(q, a.label))
: IDE_COMMANDS;
quickOpenResults.innerHTML =
`<div class="qo-hint">Commands — press Enter to run</div>` +
filtered.slice(0, 20).map((c, i) => {
const hl = q
? c.label.replace(new RegExp(`(${escapeRegex(q)})`, 'gi'), '<span class="qo-mark">$1</span>')
: escapeHtml(c.label);
return `<div class="qo-result${i === qoSelectedIdx ? ' selected' : ''}" data-cmd-idx="${IDE_COMMANDS.indexOf(c)}">
<span class="qo-icon">${c.icon}</span>
<span class="qo-name">${hl}</span>
</div>`;
}).join('');
quickOpenResults.querySelectorAll('.qo-result').forEach(el => {
el.addEventListener('click', () => {
const idx = parseInt(el.dataset.cmdIdx, 10);
if (IDE_COMMANDS[idx]) IDE_COMMANDS[idx].action();
hideQuickOpen();
});
});
return;
}
// File mode
const q = query.toLowerCase();
const filtered = q
? allFiles
.map(f => ({ f, score: fuzzyScore(q, f.name) + fuzzyScore(q, f.path) * 0.5 }))
.filter(x => x.score > 0)
.sort((a, b) => b.score - a.score)
.map(x => x.f)
: allFiles.slice(0, 20);
quickOpenResults.innerHTML =
`<div class="qo-hint">Files — type <span style="color:var(--accent)">&gt;</span> for commands</div>` +
filtered.slice(0, 40).map((f, i) => {
const highlight = q ? f.name.replace(new RegExp(`(${escapeRegex(q)})`, 'gi'), '<span class="qo-mark">$1</span>') : escapeHtml(f.name);
return `<div class="qo-result${i === qoSelectedIdx ? ' selected' : ''}" data-path="${escapeHtml(f.path)}" data-name="${escapeHtml(f.name)}">
<span class="qo-icon">·</span>
<span class="qo-name">${highlight}</span>
<span class="qo-path">${escapeHtml(f.path.split('/').slice(0, -1).join('/'))}</span>
</div>`;
}).join('');
quickOpenResults.querySelectorAll('.qo-result').forEach(el => {
if (el.dataset.path) {
el.addEventListener('click', () => {
openFileFromTree(el.dataset.path, el.dataset.name);
hideQuickOpen();
});
}
});
}
quickOpenInput.addEventListener('input', () => {
qoSelectedIdx = 0;
renderQuickOpenResults(quickOpenInput.value);
});
quickOpenInput.addEventListener('keydown', e => {
const results = quickOpenResults.querySelectorAll('.qo-result');
if (e.key === 'ArrowDown') {
e.preventDefault();
qoSelectedIdx = Math.min(qoSelectedIdx + 1, results.length - 1);
results.forEach((el, i) => el.classList.toggle('selected', i === qoSelectedIdx));
} else if (e.key === 'ArrowUp') {
e.preventDefault();
qoSelectedIdx = Math.max(qoSelectedIdx - 1, 0);
results.forEach((el, i) => el.classList.toggle('selected', i === qoSelectedIdx));
} else if (e.key === 'Enter') {
const sel = results[qoSelectedIdx];
if (sel) {
if (sel.dataset.cmdIdx !== undefined) {
const idx = parseInt(sel.dataset.cmdIdx, 10);
if (IDE_COMMANDS[idx]) IDE_COMMANDS[idx].action();
hideQuickOpen();
} else if (sel.dataset.path) {
openFileFromTree(sel.dataset.path, sel.dataset.name);
hideQuickOpen();
}
}
} else if (e.key === 'Escape') {
hideQuickOpen();
}
});
quickOpenModal.addEventListener('click', e => {
if (e.target === quickOpenModal) hideQuickOpen();
});
// Global keyboard shortcuts
document.addEventListener('keydown', e => {
if ((e.ctrlKey || e.metaKey) && e.key === 'p') {
e.preventDefault();
showQuickOpen();
}
if ((e.ctrlKey || e.metaKey) && e.key === 'w') {
e.preventDefault();
if (activeTabId !== null) closeTab(activeTabId);
}
if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key === 'P') {
// Command palette with > prefix
e.preventDefault();
showQuickOpen();
setTimeout(() => { quickOpenInput.value = '>'; renderQuickOpenResults('>'); }, 10);
}
if ((e.ctrlKey || e.metaKey) && e.key === 'Tab') {
e.preventDefault();
// Cycle tabs
if (tabs.length < 2) return;
const idx = tabs.findIndex(t => t.id === activeTabId);
const next = e.shiftKey ? (idx - 1 + tabs.length) % tabs.length : (idx + 1) % tabs.length;
switchTab(tabs[next].id);
}
// Toggle file tree
if ((e.ctrlKey || e.metaKey) && e.key === 'b') {
e.preventDefault();
toggleFileTree();
}
// Toggle bottom panel
if ((e.ctrlKey || e.metaKey) && e.key === 'j') {
e.preventDefault();
toggleBottomPanel();
}
// Select next occurrence (Ctrl+D)
if ((e.ctrlKey || e.metaKey) && e.key === 'd' && !e.shiftKey) {
e.preventDefault();
selectNextOccurrence();
}
});
// ═══════════════════════════════════════════════════════════════════════════
// Build / Run
// ═══════════════════════════════════════════════════════════════════════════
function clearBuildOutput() {
document.getElementById('panel-build').innerHTML = '';
}
// Error pattern: "path/to/file.el:12:5: error: ..."
const ERROR_LOC_RE = /^(.+\.el):(\d+)(?::(\d+))?:/;
function appendBuildLine(type, text) {
const panel = document.getElementById('panel-build');
const line = document.createElement('span');
// Colorize error/warning keywords
const lc = text.toLowerCase();
let cls = type;
if (type === 'info' || type === 'output') {
if (/\berror\b/.test(lc)) cls = 'error';
else if (/\bwarning\b/.test(lc)) cls = 'warning';
}
line.className = `out-line ${cls}`;
line.textContent = text;
// Make error/warning lines clickable if they have a file location
const match = ERROR_LOC_RE.exec(text);
if (match && (cls === 'error' || cls === 'warning')) {
const filePath = match[1];
const lineNum = parseInt(match[2], 10);
line.classList.add('clickable');
line.title = `Open ${filePath}:${lineNum}`;
line.addEventListener('click', () => {
const name = filePath.split('/').pop();
openFileFromTree(filePath, name).then(() => {
setTimeout(() => {
try {
const lineInfo = editor.state.doc.line(lineNum);
editor.dispatch({
selection: { anchor: lineInfo.from },
scrollIntoView: true,
});
editor.focus();
} catch {}
}, 50);
});
});
}
panel.appendChild(line);
// Keep #bottom-content scrolled
const bc = document.getElementById('bottom-content');
bc.scrollTop = bc.scrollHeight;
}
async function runBuildOrRun(action) {
const tab = tabs.find(t => t.id === activeTabId);
switchBottomTab('build');
clearBuildOutput();
appendBuildLine('info', `Starting ${action}...`);
const startTime = Date.now();
const target = document.getElementById('target-select').value;
const body = action === 'build'
? { target, file: tab ? tab.path : undefined }
: { file: tab ? tab.path : 'src/main.el' };
let errorCount = 0;
let warnCount = 0;
try {
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 });
const lines = buffer.split('\n');
buffer = lines.pop();
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();
if (eventType === 'done') {
const code = parseInt(data, 10);
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
if (code === 0) {
appendBuildLine('success', `✓ Build succeeded in ${elapsed}s`);
} else {
appendBuildLine('error', `${errorCount} error${errorCount !== 1 ? 's' : ''}, ${warnCount} warning${warnCount !== 1 ? 's' : ''} — exited ${code} (${elapsed}s)`);
}
} else {
if (/\berror\b/i.test(data)) errorCount++;
if (/\bwarning\b/i.test(data)) warnCount++;
appendBuildLine(eventType, data);
}
eventType = 'info';
}
}
}
} catch (err) {
appendBuildLine('error', `Request failed: ${err}`);
}
}
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');
const warnings = diags.filter(d => d.severity === 'warning');
badge.textContent = errors.length;
badge.style.display = errors.length > 0 ? '' : 'none';
// Update status bar error/warning counts
const statusErrors = document.getElementById('status-errors');
const statusWarnings = document.getElementById('status-warnings');
if (statusErrors) {
if (errors.length > 0) {
statusErrors.textContent = `${errors.length}`;
statusErrors.style.display = '';
} else {
statusErrors.style.display = 'none';
}
}
if (statusWarnings) {
if (warnings.length > 0) {
statusWarnings.textContent = `${warnings.length}`;
statusWarnings.style.display = '';
} else {
statusWarnings.style.display = '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" data-line="${d.line || 0}">
<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('');
panel.querySelectorAll('.problem-item[data-line]').forEach(el => {
const lineNum = parseInt(el.dataset.line, 10);
if (!lineNum) return;
el.addEventListener('click', () => {
try {
const lineInfo = editor.state.doc.line(lineNum);
editor.dispatch({ selection: { anchor: lineInfo.from }, scrollIntoView: true });
editor.focus();
} catch {}
});
});
}
// ═══════════════════════════════════════════════════════════════════════════
// Search panel — with case/word/regex toggles and replace
// ═══════════════════════════════════════════════════════════════════════════
let searchOpts = { matchCase: false, wholeWord: false, useRegex: false };
function wireSearchToggle(id, key, shortcut) {
const btn = document.getElementById(id);
if (!btn) return;
btn.addEventListener('click', () => {
searchOpts[key] = !searchOpts[key];
btn.classList.toggle('active', searchOpts[key]);
});
}
wireSearchToggle('search-toggle-case', 'matchCase', 'Alt+C');
wireSearchToggle('search-toggle-word', 'wholeWord', 'Alt+W');
wireSearchToggle('search-toggle-regex', 'useRegex', 'Alt+R');
// Alt+C/W/R keyboard shortcuts in search panel
document.getElementById('search-input').addEventListener('keydown', e => {
if (e.key === 'Enter') { runSearch(); return; }
if (e.altKey && e.key.toLowerCase() === 'c') { document.getElementById('search-toggle-case').click(); return; }
if (e.altKey && e.key.toLowerCase() === 'w') { document.getElementById('search-toggle-word').click(); return; }
if (e.altKey && e.key.toLowerCase() === 'r') { document.getElementById('search-toggle-regex').click(); return; }
});
document.getElementById('btn-search').addEventListener('click', runSearch);
document.getElementById('btn-replace-all').addEventListener('click', async () => {
const query = document.getElementById('search-input').value.trim();
const replaceStr = document.getElementById('search-replace-input').value;
if (!query) return;
// Replace in currently open file
if (!editor || activeTabId === null) { showToast('No file open'); return; }
const tab = tabs.find(t => t.id === activeTabId);
if (!tab) return;
const content = editor.state.doc.toString();
let newContent;
try {
if (searchOpts.useRegex) {
const flags = searchOpts.matchCase ? 'g' : 'gi';
newContent = content.replace(new RegExp(query, flags), replaceStr);
} else {
let pattern = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
if (searchOpts.wholeWord) pattern = `\\b${pattern}\\b`;
const flags = searchOpts.matchCase ? 'g' : 'gi';
newContent = content.replace(new RegExp(pattern, flags), replaceStr);
}
} catch (err) {
showToast('Invalid regex: ' + err.message);
return;
}
if (newContent === content) { showToast('No matches'); return; }
editor.dispatch({ changes: { from: 0, to: content.length, insert: newContent } });
tab.content = newContent;
await saveCurrentFile();
showToast('Replaced all occurrences');
});
async function runSearch() {
const query = document.getElementById('search-input').value.trim();
if (!query) return;
const results = document.getElementById('search-results');
results.innerHTML = '<div style="color:var(--text3)">Searching…</div>';
try {
const params = new URLSearchParams({ query });
if (searchOpts.matchCase) params.set('case_sensitive', '1');
if (searchOpts.wholeWord) params.set('whole_word', '1');
if (searchOpts.useRegex) params.set('regex', '1');
const resp = await fetch(`/api/search?${params}`);
if (!resp.ok) throw new Error(await resp.text());
const data = await resp.json();
if (data.length === 0) {
results.innerHTML = '<div style="color:var(--text3);padding:4px 0">No results found.</div>';
return;
}
results.innerHTML = data.map(r => `
<div class="search-result" data-path="${escapeHtml(r.path)}" data-name="${escapeHtml(r.path.split('/').pop())}" data-line="${r.line}">
<span class="search-path">${escapeHtml(r.path)}</span>
<span class="search-loc">:${r.line}</span>
<span class="search-snippet">${escapeHtml(r.snippet)}</span>
</div>
`).join('');
results.querySelectorAll('.search-result').forEach(el => {
el.addEventListener('click', () => {
const path = el.dataset.path;
const name = el.dataset.name;
const line = parseInt(el.dataset.line, 10);
switchBottomTab('search');
openFileFromTree(path, name).then(() => {
// Jump to line
setTimeout(() => {
try {
const lineInfo = editor.state.doc.line(line);
editor.dispatch({
selection: { anchor: lineInfo.from },
scrollIntoView: true,
});
editor.focus();
} catch { /* ignore */ }
}, 50);
});
});
});
if (data.length >= 200) {
const note = document.createElement('div');
note.style.cssText = 'color:var(--text3);font-size:10px;padding:4px 0;';
note.textContent = 'Results capped at 200. Refine your query.';
results.appendChild(note);
}
} catch (err) {
results.innerHTML = `<div style="color:var(--red)">Error: ${escapeHtml(String(err))}</div>`;
}
}
// ═══════════════════════════════════════════════════════════════════════════
// 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',
protocol: '#4ade80',
};
const graphState = {
data: { nodes: [], edges: [] },
nodes: [],
dragging: false,
dragStart: null,
activatedNodes: new Set(), // node IDs currently "activated" for animation
activationFrame: 0, // animation frame counter
animating: false,
};
function nodeColor(kind) {
return NODE_COLORS[kind] || '#7a8a9a';
}
function resizeCanvas() {
const rect = canvas.parentElement.getBoundingClientRect();
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();
graphState.data = graph;
initGraphLayout(graph);
updateActivationHighlights(source);
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;
graphState.nodes = 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 };
});
for (let iter = 0; iter < 60; iter++) tickForce();
}
function tickForce() {
const nodes = graphState.nodes;
const edges = graphState.data.edges || [];
const w = canvas.width || 280;
const h = canvas.height || 300;
const k = 80;
const repulsion = 3000;
nodes.forEach(n => { n.fx = 0; n.fy = 0; });
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;
}
}
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;
}
nodes.forEach(n => {
n.fx += (w/2 - n.x) * 0.01;
n.fy += (h/2 - n.y) * 0.01;
});
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;
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 = graphState.nodes;
const edges = graphState.data.edges || [];
const w = canvas.width;
const h = canvas.height;
const z = graphState.zoom || 1;
const px = graphState.panX || 0;
const py = graphState.panY || 0;
ctx.clearRect(0, 0, w, h);
const bgColor = getComputedStyle(document.documentElement).getPropertyValue('--bg').trim() || '#080b0f';
ctx.fillStyle = bgColor;
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;
}
// Apply zoom+pan transform
ctx.save();
ctx.translate(px, py);
ctx.scale(z, z);
const nodeIndex = Object.fromEntries(nodes.map(n => [n.id, n]));
ctx.strokeStyle = 'rgba(255,255,255,0.08)';
ctx.lineWidth = 1 / z;
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();
const angle = Math.atan2(b.y - a.y, b.x - a.x);
const arrowLen = 6 / z;
const tx = b.x - Math.cos(angle) * 12 / z;
const ty = b.y - Math.sin(angle) * 12 / z;
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();
}
ctx.font = `bold ${10 / z}px DM Mono, monospace`;
ctx.textAlign = 'center';
// Draw activated nodes with pulsing rings first (behind nodes)
const activationPulse = 0.5 + 0.5 * Math.sin(graphState.activationFrame * 0.12);
for (const node of nodes) {
if (!graphState.activatedNodes.has(node.id)) continue;
const r = (node.kind === 'builtin' ? 6 : 9) / z;
// Draw 3 pulsing rings
for (let ring = 1; ring <= 3; ring++) {
const ringR = r + (ring * 8 * activationPulse) / z;
const alpha = (0.4 - ring * 0.1) * activationPulse;
ctx.beginPath();
ctx.arc(node.x, node.y, ringR, 0, Math.PI * 2);
ctx.strokeStyle = `rgba(192,132,252,${alpha})`;
ctx.lineWidth = 1.5 / z;
ctx.stroke();
}
}
for (const node of nodes) {
const isActivated = graphState.activatedNodes.has(node.id);
const color = isActivated ? '#c084fc' : nodeColor(node.kind);
const r = (node.kind === 'builtin' ? 6 : 9) / z;
ctx.beginPath();
ctx.arc(node.x, node.y, r, 0, Math.PI * 2);
ctx.fillStyle = color;
ctx.fill();
if (node.kind !== 'builtin' && node.kind !== 'primitive') {
const shadowIntensity = isActivated ? 16 + 8 * activationPulse : 8;
ctx.shadowColor = color;
ctx.shadowBlur = shadowIntensity / z;
ctx.beginPath();
ctx.arc(node.x, node.y, r, 0, Math.PI * 2);
ctx.fillStyle = color + '66';
ctx.fill();
ctx.shadowBlur = 0;
}
ctx.fillStyle = (node.kind === 'builtin' && !isActivated) ? '#4a5a6a' : '#e8edf3';
ctx.fillText(node.name, node.x, node.y - r - 4 / z);
}
ctx.restore();
}
// ── Graph zoom ────────────────────────────────────────────────────────────
graphState.zoom = 1.0;
graphState.panX = 0;
graphState.panY = 0;
document.getElementById('graph-zoom-in').addEventListener('click', () => {
graphState.zoom = Math.min(3, graphState.zoom * 1.25);
updateZoomLabel();
drawGraph();
});
document.getElementById('graph-zoom-out').addEventListener('click', () => {
graphState.zoom = Math.max(0.3, graphState.zoom / 1.25);
updateZoomLabel();
drawGraph();
});
document.getElementById('graph-reset').addEventListener('click', () => {
graphState.zoom = 1;
graphState.panX = 0;
graphState.panY = 0;
updateZoomLabel();
drawGraph();
});
function updateZoomLabel() {
document.getElementById('graph-zoom-label').textContent = Math.round(graphState.zoom * 100) + '%';
}
// Animation loop for activated node pulsing
function startActivationAnimation() {
if (graphState.animating) return;
graphState.animating = true;
function animLoop() {
if (graphState.activatedNodes.size === 0) {
graphState.animating = false;
return;
}
graphState.activationFrame++;
drawGraph();
requestAnimationFrame(animLoop);
}
requestAnimationFrame(animLoop);
}
// Detect activate statements in source and highlight corresponding nodes
function updateActivationHighlights(source) {
const activateRe = /activate\s+(\w+)\s+where/g;
const found = new Set();
let m;
while ((m = activateRe.exec(source)) !== null) {
found.add(m[1]);
}
const changed = found.size !== graphState.activatedNodes.size ||
[...found].some(id => !graphState.activatedNodes.has(id));
if (changed) {
graphState.activatedNodes = found;
if (found.size > 0) {
startActivationAnimation();
} else {
drawGraph();
}
}
}
canvas.addEventListener('wheel', e => {
e.preventDefault();
const delta = e.deltaY > 0 ? 0.9 : 1.1;
graphState.zoom = Math.max(0.3, Math.min(3, graphState.zoom * delta));
updateZoomLabel();
drawGraph();
}, { passive: false });
// ── Graph node click ──────────────────────────────────────────────────────
function getNodeAtPoint(x, y) {
const z = graphState.zoom;
const px = graphState.panX, py = graphState.panY;
for (const node of graphState.nodes) {
const nx = node.x * z + px;
const ny = node.y * z + py;
const r = (node.kind === 'builtin' ? 6 : 9) * z + 4;
if (Math.hypot(x - nx, y - ny) <= r) return node;
}
return null;
}
canvas.addEventListener('click', e => {
const rect = canvas.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
const node = getNodeAtPoint(x, y);
const detail = document.getElementById('graph-node-detail');
if (node) {
const isActivated = graphState.activatedNodes.has(node.id);
detail.innerHTML = `
<span class="gnd-kind">${node.kind}</span>
<span class="gnd-name">${escapeHtml(node.name)}</span>
${isActivated ? '<span style="color:var(--purple);margin-left:6px;font-size:9px;">⟁ activated</span>' : ''}
${node.fields && node.fields.length > 0 ? `<div style="margin-top:2px;color:var(--text3);font-size:10px;">${node.fields.slice(0, 3).map(f => escapeHtml(f)).join(', ')}</div>` : ''}
`;
detail.classList.add('visible');
// Navigate editor to type definition
const source = editor.state.doc.toString();
const typeDefRe = new RegExp(`(?:^|\\n)(type|enum|protocol)\\s+${escapeRegex(node.name)}\\s*[{(]`, 'm');
const match = typeDefRe.exec(source);
if (match) {
try {
const pos = match.index + (source[match.index] === '\n' ? 1 : 0);
const lineInfo = editor.state.doc.lineAt(pos);
editor.dispatch({ selection: { anchor: lineInfo.from }, scrollIntoView: true });
editor.focus();
} catch {}
}
} else {
detail.classList.remove('visible');
}
});
canvas.addEventListener('mousedown', e => {
// Don't start drag on click (handled above)
graphState.dragging = true;
graphState.dragStart = { x: e.clientX, y: e.clientY };
});
canvas.addEventListener('mousemove', e => {
if (!graphState.dragging) return;
const dx = e.clientX - graphState.dragStart.x;
const dy = e.clientY - graphState.dragStart.y;
graphState.dragStart = { x: e.clientX, y: e.clientY };
graphState.panX = (graphState.panX || 0) + dx;
graphState.panY = (graphState.panY || 0) + dy;
drawGraph();
});
canvas.addEventListener('mouseup', () => { graphState.dragging = false; });
canvas.addEventListener('mouseleave', () => { graphState.dragging = false; });
// ═══════════════════════════════════════════════════════════════════════════
// Minimap
// ═══════════════════════════════════════════════════════════════════════════
const minimapCanvas = document.getElementById('minimap-canvas');
const minimapCtx = minimapCanvas.getContext('2d');
const minimapViewport = document.getElementById('minimap-viewport');
const MINIMAP_WIDTH = 60;
let minimapAnimFrame = null;
function updateMinimap() {
if (minimapAnimFrame) cancelAnimationFrame(minimapAnimFrame);
minimapAnimFrame = requestAnimationFrame(_drawMinimap);
}
function _drawMinimap() {
const wrapper = document.getElementById('editor-wrapper');
const rect = wrapper.getBoundingClientRect();
const h = rect.height;
minimapCanvas.width = MINIMAP_WIDTH;
minimapCanvas.height = h;
const source = editor.state.doc.toString();
const lines = source.split('\n');
const totalLines = Math.max(lines.length, 1);
const lineH = Math.max(1, Math.min(3, h / totalLines));
const bgColor = getComputedStyle(document.documentElement).getPropertyValue('--bg').trim() || '#080b0f';
minimapCtx.fillStyle = bgColor;
minimapCtx.fillRect(0, 0, MINIMAP_WIDTH, h);
// Draw each line as a colored bar
for (let i = 0; i < lines.length; i++) {
const y = i * lineH;
if (y > h) break;
const line = lines[i];
if (!line.trim()) continue;
// Color-code by type of content
let color = 'rgba(122,138,154,0.4)';
const t = line.trim();
if (t.startsWith('//')) color = 'rgba(74,90,106,0.5)';
else if (t.startsWith('fn ')) color = 'rgba(56,189,248,0.6)';
else if (t.startsWith('type ') || t.startsWith('enum ')) color = 'rgba(167,139,250,0.6)';
else if (t.startsWith('let ')) color = 'rgba(74,222,128,0.5)';
minimapCtx.fillStyle = color;
const len = Math.min(MINIMAP_WIDTH, line.length * 0.4);
minimapCtx.fillRect(2, y, len, Math.max(1, lineH - 0.5));
}
// Draw viewport indicator
const cmScrollEl = wrapper.querySelector('.cm-scroller');
if (cmScrollEl) {
const scrollTop = cmScrollEl.scrollTop;
const scrollH = cmScrollEl.scrollHeight;
const clientH = cmScrollEl.clientHeight;
const ratio = scrollH > 0 ? clientH / scrollH : 1;
const vpTop = scrollH > 0 ? (scrollTop / scrollH) * h : 0;
const vpH = ratio * h;
minimapViewport.style.top = vpTop + 'px';
minimapViewport.style.height = vpH + 'px';
}
}
// Hook minimap updates to editor changes
setInterval(updateMinimap, 500); // fallback polling
// Minimap click-to-jump
const minimapContainer = document.getElementById('minimap-container');
minimapContainer.addEventListener('click', e => {
const rect = minimapContainer.getBoundingClientRect();
const relY = e.clientY - rect.top;
const ratio = relY / rect.height;
const source = editor.state.doc.toString();
const totalLines = Math.max(source.split('\n').length, 1);
const targetLine = Math.max(1, Math.min(Math.round(ratio * totalLines), totalLines));
try {
const lineInfo = editor.state.doc.line(targetLine);
editor.dispatch({ selection: { anchor: lineInfo.from }, scrollIntoView: true });
editor.focus();
} catch {}
});
// Also allow dragging on the minimap to scroll
let minimapDragging = false;
minimapContainer.addEventListener('mousedown', () => { minimapDragging = true; });
document.addEventListener('mousemove', e => {
if (!minimapDragging) return;
const rect = minimapContainer.getBoundingClientRect();
const relY = Math.max(0, Math.min(e.clientY - rect.top, rect.height));
const ratio = relY / rect.height;
const cmScroller = document.querySelector('.cm-scroller');
if (cmScroller) {
cmScroller.scrollTop = ratio * (cmScroller.scrollHeight - cmScroller.clientHeight);
}
});
document.addEventListener('mouseup', () => { minimapDragging = false; });
// ═══════════════════════════════════════════════════════════════════════════
// Sticky scroll — show current function/type header at top of editor
// ═══════════════════════════════════════════════════════════════════════════
const stickyScrollEl = document.getElementById('sticky-scroll');
const STICKY_DEFS = /^(fn |type |enum |protocol |impl |test |seed )/;
let _stickyLineNum = null;
function updateStickyScroll() {
if (!editor) return;
const cmScroller = document.querySelector('.cm-scroller');
if (!cmScroller) return;
// Use CM6's actual viewport to determine what line is at top
const rect = cmScroller.getBoundingClientRect();
const topPos = editor.posAtCoords({ x: rect.left + 1, y: rect.top + 2 });
if (topPos == null) {
stickyScrollEl.classList.remove('visible');
return;
}
const firstVisibleLine = editor.state.doc.lineAt(topPos).number;
const doc = editor.state.doc;
if (firstVisibleLine <= 1) {
stickyScrollEl.classList.remove('visible');
return;
}
// Find the last definition line before firstVisibleLine
let bestText = null;
let bestLineNum = null;
for (let i = firstVisibleLine - 1; i >= 1; i--) {
try {
const line = doc.line(i);
const text = line.text.trim();
if (STICKY_DEFS.test(text)) {
bestText = text;
bestLineNum = i;
break;
}
} catch {}
}
if (bestText) {
const m = bestText.match(/^(\w+)\s+(\S+)/);
if (m) {
_stickyLineNum = bestLineNum;
stickyScrollEl.innerHTML = `<span class="sticky-kind">${escapeHtml(m[1])}</span><span class="sticky-name">${escapeHtml(bestText.slice(m[1].length + 1))}</span><span class="sticky-goto" title="Jump to definition">:${bestLineNum}</span>`;
stickyScrollEl.classList.add('visible');
}
} else {
stickyScrollEl.classList.remove('visible');
_stickyLineNum = null;
}
}
// Click sticky scroll to jump to definition
stickyScrollEl.addEventListener('click', () => {
if (_stickyLineNum == null) return;
try {
const lineInfo = editor.state.doc.line(_stickyLineNum);
editor.dispatch({ selection: { anchor: lineInfo.from }, scrollIntoView: true });
editor.focus();
} catch {}
});
// Attach scroll listener to CM scroller when it appears
function attachStickyScrollListener() {
const cmScroller = document.querySelector('.cm-scroller');
if (cmScroller) {
cmScroller.addEventListener('scroll', updateStickyScroll, { passive: true });
}
}
setTimeout(attachStickyScrollListener, 500);
// ═══════════════════════════════════════════════════════════════════════════
// Git Diff panel
// ═══════════════════════════════════════════════════════════════════════════
async function loadDiff(filePath) {
const output = document.getElementById('diff-output');
const label = document.getElementById('diff-file-label');
const path = filePath || (tabs.find(t => t.id === activeTabId) || {}).path;
if (!path) {
output.innerHTML = '<span style="color:var(--text3)">No file selected.</span>';
return;
}
label.textContent = path;
output.innerHTML = '<span style="color:var(--text3)">Loading diff...</span>';
try {
const resp = await fetch(`/api/git/diff?path=${encodeURIComponent(path)}`);
if (!resp.ok) throw new Error(await resp.text());
const diff = await resp.json();
if (!diff || !diff.trim()) {
output.innerHTML = '<span style="color:var(--green)">No changes.</span>';
return;
}
output.innerHTML = diff.split('\n').map(line => {
let cls = 'diff-ctx';
if (line.startsWith('+++') || line.startsWith('---')) cls = 'diff-meta';
else if (line.startsWith('+')) cls = 'diff-add';
else if (line.startsWith('-')) cls = 'diff-del';
else if (line.startsWith('@@')) cls = 'diff-hunk';
return `<span class="diff-line ${cls}">${escapeHtml(line)}</span>`;
}).join('\n');
} catch (err) {
output.innerHTML = `<span style="color:var(--red)">Error: ${escapeHtml(String(err))}</span>`;
}
}
document.getElementById('btn-diff-refresh').addEventListener('click', () => loadDiff(null));
// ═══════════════════════════════════════════════════════════════════════════
// Definition / References lookup (Ctrl+click or F12/Shift+F12)
// ═══════════════════════════════════════════════════════════════════════════
async function goToDefinition(pos) {
const source = editor.state.doc.toString();
try {
const resp = await fetch(`/api/definition?source=${encodeURIComponent(source)}&pos=${pos}`);
if (!resp.ok) return;
const loc = await resp.json();
if (!loc) { appendBuildLine('info', 'No definition found.'); return; }
const lineInfo = editor.state.doc.line(loc.line);
editor.dispatch({ selection: { anchor: lineInfo.from + Math.max(0, loc.col - 1) }, scrollIntoView: true });
editor.focus();
appendBuildLine('info', `Definition at line ${loc.line}: ${loc.snippet}`);
} catch {}
}
async function findReferences(pos) {
const source = editor.state.doc.toString();
try {
const resp = await fetch(`/api/references?source=${encodeURIComponent(source)}&pos=${pos}`);
if (!resp.ok) return;
const refs = await resp.json();
if (!refs.length) { appendBuildLine('info', 'No references found.'); return; }
switchBottomTab('build');
appendBuildLine('info', `Found ${refs.length} reference(s):`);
for (const r of refs) {
appendBuildLine('output', ` Line ${r.line}:${r.col} ${r.snippet}`);
}
} catch {}
}
// ═══════════════════════════════════════════════════════════════════════════
// Go to line (Ctrl+G)
// ═══════════════════════════════════════════════════════════════════════════
function goToLine() {
const input = prompt('Go to line:');
if (!input) return;
const lineNum = parseInt(input.trim(), 10);
if (isNaN(lineNum) || lineNum < 1) return;
try {
const doc = editor.state.doc;
const clampedLine = Math.min(Math.max(1, lineNum), doc.lines);
const lineInfo = doc.line(clampedLine);
editor.dispatch({
selection: { anchor: lineInfo.from },
scrollIntoView: true,
});
editor.focus();
showToast(`Line ${clampedLine}`);
} catch {}
}
// ═══════════════════════════════════════════════════════════════════════════
// Toggle line comment (Ctrl+/)
// ═══════════════════════════════════════════════════════════════════════════
function toggleLineComment() {
const sel = editor.state.selection.main;
const doc = editor.state.doc;
const startLine = doc.lineAt(sel.from);
const endLine = doc.lineAt(sel.to);
// Determine if all selected lines are commented
const lines = [];
for (let i = startLine.number; i <= endLine.number; i++) {
lines.push(doc.line(i));
}
const allCommented = lines.every(l => l.text.trimStart().startsWith('//'));
const changes = lines.map(l => {
if (allCommented) {
// Remove comment
const ci = l.text.indexOf('//');
return { from: l.from + ci, to: l.from + ci + 2, insert: '' };
} else {
return { from: l.from, insert: '//' };
}
});
editor.dispatch({ changes });
editor.focus();
}
// ═══════════════════════════════════════════════════════════════════════════
// Tab keyboard shortcuts — Cmd+1..9 to switch tabs, middle-click to close
// ═══════════════════════════════════════════════════════════════════════════
document.addEventListener('keydown', e => {
if ((e.ctrlKey || e.metaKey) && !e.shiftKey && !e.altKey) {
const num = parseInt(e.key, 10);
if (num >= 1 && num <= 9) {
const idx = num - 1;
if (idx < tabs.length) {
e.preventDefault();
switchTab(tabs[idx].id);
}
}
}
// F12 — go to definition
if (e.key === 'F12' && !e.shiftKey) {
e.preventDefault();
const pos = editor.state.selection.main.head;
goToDefinition(pos);
}
// Shift+F12 — find references
if (e.key === 'F12' && e.shiftKey) {
e.preventDefault();
const pos = editor.state.selection.main.head;
findReferences(pos);
}
});
// Middle-click to close tab (mousedown to prevent scroll)
document.getElementById('editor-tabs').addEventListener('mousedown', e => {
if (e.button === 1) { // middle click
e.preventDefault();
const tab = e.target.closest('.editor-tab');
if (tab) {
const id = parseInt(tab.dataset.tabId, 10);
closeTab(id);
}
}
});
// ═══════════════════════════════════════════════════════════════════════════
// 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();
if (name === 'terminal') initTerminal();
if (name === 'outline') refreshOutline();
if (name === 'diff') loadDiff(null);
if (name === 'knowledge') document.getElementById('kg-search-input').focus();
}
document.querySelectorAll('.bottom-tab').forEach(tab =>
tab.addEventListener('click', () => switchBottomTab(tab.dataset.panel))
);
// ═══════════════════════════════════════════════════════════════════════════
// Plugins panel (improved)
// ═══════════════════════════════════════════════════════════════════════════
async function loadPlugins() {
const list = document.getElementById('plugin-list');
try {
const resp = await fetch('/api/plugins');
const plugins = await resp.json();
const installed = plugins.filter(p => p.installed);
const available = plugins.filter(p => !p.installed);
function renderPlugin(p) {
const hooks = (p.hooks || []).map(h =>
`<span class="plugin-badge hook">${h.replace('_', '')}</span>`
).join('');
const fp = p.first_party ? `<span class="plugin-badge first-party">1st party</span>` : '';
const status = p.installed
? `<span class="plugin-badge installed">installed</span>`
: `<span class="plugin-badge available">available</span>`;
const toggleBtn = p.installed
? (p.enabled
? `<button class="btn" style="font-size:10px;padding:3px 8px" onclick="pluginToggle('${escapeHtml(p.name)}',false)">Disable</button>`
: `<button class="btn btn-primary" style="font-size:10px;padding:3px 8px" onclick="pluginToggle('${escapeHtml(p.name)}',true)">Enable</button>`)
: '';
const actionBtn = 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>`;
return `<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 class="plugin-badges">${status}${fp}${hooks}</div>
</div>
${toggleBtn}
${actionBtn}
</div>`;
}
let html = '';
if (installed.length) {
html += `<div class="plugin-section-title">Installed</div>`;
html += installed.map(renderPlugin).join('');
}
if (available.length) {
html += `<div class="plugin-section-title" style="margin-top:8px">Available</div>`;
html += available.map(renderPlugin).join('');
}
list.innerHTML = html || '<div style="color:var(--text3)">No plugins.</div>';
} 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 }),
});
// Auto theme-switch for theme plugins
if (name === 'el-theme-light') applyTheme('light');
await loadPlugins();
};
window.pluginRemove = async (name) => {
await fetch('/api/plugins/remove', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name }),
});
if (name === 'el-theme-light') applyTheme('dark');
await loadPlugins();
};
window.pluginToggle = async (name, enable) => {
await fetch(enable ? '/api/plugins/enable' : '/api/plugins/disable', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name }),
});
await loadPlugins();
};
// ═══════════════════════════════════════════════════════════════════════════
// Reasoning panel (hypothesis mode — legacy)
// ═══════════════════════════════════════════════════════════════════════════
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 tab = tabs.find(t => t.id === activeTabId);
const resp = await fetch('/api/reason', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
hypothesis,
context: (tab ? tab.content : editor.state.doc.toString()).slice(0, 2000),
mode: 'hypothesis',
}),
});
const result = await resp.json();
const verdict = result.verdict || 'unresolved';
const verdictClass = verdict === 'supported' ? 'supported'
: 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}">${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();
});
// ═══════════════════════════════════════════════════════════════════════════
// Neuron Pair Programming Panel
// ═══════════════════════════════════════════════════════════════════════════
let neuronHistory = []; // [{role, content}]
let neuronPanelVisible = false;
let neuronThinking = false;
const neuronPanel = document.getElementById('neuron-panel');
const neuronMessages = document.getElementById('neuron-messages');
const neuronInput = document.getElementById('neuron-input');
const neuronSend = document.getElementById('neuron-send');
const neuronStatusDot = document.getElementById('neuron-status-dot');
const neuronPanelStatus = document.getElementById('neuron-panel-status');
function toggleNeuronPanel() {
neuronPanelVisible = !neuronPanelVisible;
neuronPanel.style.display = neuronPanelVisible ? 'flex' : 'none';
const btn = document.getElementById('btn-neuron-toggle');
btn.classList.toggle('neuron-active', neuronPanelVisible);
}
document.getElementById('btn-neuron-toggle').addEventListener('click', toggleNeuronPanel);
// Quick action buttons
document.getElementById('nq-explain').addEventListener('click', () => {
const sel = editor.state.selection.main;
const selection = sel.empty ? '' : editor.state.doc.sliceString(sel.from, sel.to);
if (selection) {
neuronInput.value = 'Explain this selected code:';
sendNeuronMessage('Explain this selected code', selection);
} else {
neuronInput.value = 'Explain what this file does';
sendNeuronMessage('Explain what this file does');
}
});
document.getElementById('nq-activate').addEventListener('click', () => {
sendNeuronMessage('What activate queries would be useful for the types in this file? Show me examples.');
});
document.getElementById('nq-types').addEventListener('click', () => {
sendNeuronMessage('Analyze the type structure in this file. What semantic relationships do you see?');
});
document.getElementById('nq-clear').addEventListener('click', () => {
neuronHistory = [];
neuronMessages.innerHTML = `
<div class="neuron-msg">
<div class="neuron-msg-label neuron-label">Neuron</div>
<div class="neuron-msg-neuron">Conversation cleared. Ready to help with your Engram code.</div>
</div>
`;
});
// Send button and Enter key
neuronSend.addEventListener('click', () => {
const msg = neuronInput.value.trim();
if (msg) {
neuronInput.value = '';
sendNeuronMessage(msg);
}
});
neuronInput.addEventListener('keydown', e => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
const msg = neuronInput.value.trim();
if (msg) {
neuronInput.value = '';
sendNeuronMessage(msg);
}
}
});
function appendNeuronMessage(role, content, codeBlocks = []) {
const wrapper = document.createElement('div');
wrapper.className = 'neuron-msg';
const label = document.createElement('div');
label.className = `neuron-msg-label ${role === 'user' ? 'user-label' : 'neuron-label'}`;
label.textContent = role === 'user' ? 'You' : 'Neuron';
wrapper.appendChild(label);
if (role === 'user') {
const bubble = document.createElement('div');
bubble.className = 'neuron-msg-user';
bubble.textContent = content;
wrapper.appendChild(bubble);
} else {
// Parse and render Neuron response with code blocks
const bubble = document.createElement('div');
bubble.className = 'neuron-msg-neuron';
// Render inline code blocks from the message text
let text = content;
// Split on ```...``` blocks
const parts = text.split(/(```[\s\S]*?```)/g);
for (const part of parts) {
if (part.startsWith('```') && part.endsWith('```')) {
const inner = part.slice(3, -3);
const nlIdx = inner.indexOf('\n');
const lang = nlIdx >= 0 ? inner.slice(0, nlIdx).trim() : '';
const code = nlIdx >= 0 ? inner.slice(nlIdx + 1) : inner;
const codeEl = document.createElement('div');
codeEl.className = 'neuron-code-block';
const header = document.createElement('div');
header.className = 'neuron-code-header';
header.innerHTML = `<span class="neuron-code-lang">${escapeHtml(lang || 'code')}</span>`;
const insertBtn = document.createElement('button');
insertBtn.className = 'neuron-code-insert';
insertBtn.textContent = '→ Insert at cursor';
insertBtn.addEventListener('click', () => {
const pos = editor.state.selection.main.head;
editor.dispatch({ changes: { from: pos, insert: code.trim() + '\n' } });
editor.focus();
showToast('Code inserted at cursor');
});
header.appendChild(insertBtn);
codeEl.appendChild(header);
const body = document.createElement('div');
body.className = 'neuron-code-body';
body.textContent = code.trim();
codeEl.appendChild(body);
bubble.appendChild(codeEl);
} else if (part.trim()) {
const textNode = document.createElement('div');
textNode.style.cssText = 'white-space: pre-wrap; word-break: break-word;';
textNode.textContent = part;
bubble.appendChild(textNode);
}
}
wrapper.appendChild(bubble);
}
neuronMessages.appendChild(wrapper);
neuronMessages.scrollTop = neuronMessages.scrollHeight;
}
function showNeuronThinking() {
const el = document.createElement('div');
el.className = 'neuron-thinking';
el.id = 'neuron-thinking-indicator';
el.innerHTML = `
<span style="color:var(--purple)">Neuron</span>
<div class="thinking-dots">
<span>·</span><span>·</span><span>·</span>
</div>
`;
neuronMessages.appendChild(el);
neuronMessages.scrollTop = neuronMessages.scrollHeight;
}
function hideNeuronThinking() {
const el = document.getElementById('neuron-thinking-indicator');
if (el) el.remove();
}
async function sendNeuronMessage(message, selection = null) {
if (neuronThinking) return;
// Auto-open panel
if (!neuronPanelVisible) toggleNeuronPanel();
// Get selection from editor if not provided
if (!selection) {
const sel = editor.state.selection.main;
if (!sel.empty) {
selection = editor.state.doc.sliceString(sel.from, sel.to);
}
}
// Get current file context
const tab = tabs.find(t => t.id === activeTabId);
const context = tab ? tab.content : editor.state.doc.toString();
// Add to history and display
neuronHistory.push({ role: 'user', content: message });
appendNeuronMessage('user', message);
neuronThinking = true;
neuronStatusDot.className = 'neuron-status-dot active';
neuronPanelStatus.textContent = 'thinking...';
neuronSend.disabled = true;
showNeuronThinking();
try {
const resp = await fetch('/api/reason', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
mode: 'pair',
message,
context: context.slice(0, 4000),
selection: selection || '',
conversation_history: neuronHistory.slice(-10), // last 10 messages for context
}),
});
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const result = await resp.json();
const responseText = result.message || 'No response.';
// Add to history
neuronHistory.push({ role: 'assistant', content: responseText });
hideNeuronThinking();
appendNeuronMessage('neuron', responseText);
// Update status
neuronStatusDot.className = `neuron-status-dot ${result.source === 'stub' ? '' : 'connected'}`;
neuronPanelStatus.textContent = result.source === 'stub' ? 'stub mode' : 'connected';
} catch (err) {
hideNeuronThinking();
appendNeuronMessage('neuron', `Error: ${err.message}\n\nMake sure the server is running and EL_ENGRAM_URL is configured.`);
neuronStatusDot.className = 'neuron-status-dot';
neuronPanelStatus.textContent = 'error';
}
neuronThinking = false;
neuronSend.disabled = false;
}
// ═══════════════════════════════════════════════════════════════════════════
// Live Activation Preview
// ═══════════════════════════════════════════════════════════════════════════
const activationPreview = document.getElementById('activation-preview');
let activationPreviewTimer = null;
let lastActivationQuery = '';
// Detect `activate TypeName where "query"` pattern near cursor
function detectActivateContext(view) {
const pos = view.state.selection.main.head;
const doc = view.state.doc;
const line = doc.lineAt(pos);
const lineText = line.text;
// Look for activate pattern on current line
const activateMatch = lineText.match(/activate\s+(\w+)\s+where\s+"([^"]*)"/);
if (!activateMatch) {
// Check if we're inside a string after `where`
const partialMatch = lineText.match(/activate\s+(\w+)\s+where\s+"([^"]*)/);
if (!partialMatch) return null;
// Check cursor is inside the string
const strStart = lineText.indexOf('"', lineText.indexOf('where'));
const strEnd = lineText.length; // partial — no closing quote
const linePos = pos - line.from;
if (linePos >= strStart && linePos <= strEnd) {
return { typeName: partialMatch[1], query: partialMatch[2], partial: true };
}
return null;
}
return { typeName: activateMatch[1], query: activateMatch[2], partial: false };
}
async function updateActivationPreview(view) {
const ctx = detectActivateContext(view);
if (!ctx || ctx.query.length < 2) {
activationPreview.classList.remove('visible');
return;
}
const key = `${ctx.typeName}:${ctx.query}`;
if (key === lastActivationQuery) return;
lastActivationQuery = key;
try {
const params = new URLSearchParams({
type_name: ctx.typeName,
query: ctx.query,
});
const resp = await fetch(`/api/lsp/activate-preview?${params}`);
if (!resp.ok) return;
const data = await resp.json();
// Update preview UI
document.getElementById('ap-count').textContent = data.count || (data.connected ? '0' : '?');
document.getElementById('ap-type-name').textContent = ctx.typeName;
document.getElementById('ap-query-text').textContent = `"${ctx.query}"`;
const nodesList = document.getElementById('ap-nodes-list');
if (!data.connected) {
nodesList.innerHTML = '<div class="ap-disconnected">Connect Engram DB for live previews</div>';
} else if (data.nodes && data.nodes.length > 0) {
nodesList.innerHTML = data.nodes.map(n => `
<div class="ap-node">
<span class="ap-node-label">${escapeHtml(n.label || n.id)}</span>
<span class="ap-node-score">${Math.round((n.score || 0) * 100)}%</span>
</div>
`).join('');
} else {
nodesList.innerHTML = '<div class="ap-disconnected">No matching nodes</div>';
}
// Position below current line
const pos = view.state.selection.main.head;
const coords = view.coordsAtPos(pos);
if (coords) {
const editorWrapper = document.getElementById('editor-wrapper');
const wrapperRect = editorWrapper.getBoundingClientRect();
activationPreview.style.left = Math.max(4, coords.left - wrapperRect.left) + 'px';
activationPreview.style.top = (coords.bottom - wrapperRect.top + 4) + 'px';
activationPreview.classList.add('visible');
}
} catch {
activationPreview.classList.remove('visible');
}
}
// ═══════════════════════════════════════════════════════════════════════════
// Knowledge Graph Explorer Panel
// ═══════════════════════════════════════════════════════════════════════════
document.getElementById('btn-kg-search').addEventListener('click', runKnowledgeSearch);
document.getElementById('kg-search-input').addEventListener('keydown', e => {
if (e.key === 'Enter') runKnowledgeSearch();
});
async function runKnowledgeSearch() {
const query = document.getElementById('kg-search-input').value.trim();
if (!query) return;
const results = document.getElementById('kg-results');
results.innerHTML = '<div class="kg-status-msg">Searching knowledge graph...</div>';
try {
const resp = await fetch('/api/reason', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
mode: 'knowledge-search',
query,
}),
});
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const data = await resp.json();
if (data.source === 'stub' || !data.nodes || data.nodes.length === 0) {
const msg = data.evidence && data.evidence.length > 0
? data.evidence[0].text
: 'No results found.';
results.innerHTML = `<div class="kg-status-msg">${escapeHtml(msg)}</div>`;
return;
}
results.innerHTML = data.nodes.map(node => `
<div class="kg-node-card" data-node-id="${escapeHtml(node.id)}" data-node-label="${escapeHtml(node.label)}" data-node-type="${escapeHtml(node.node_type)}">
<div class="kg-node-header">
<span class="kg-node-label">${escapeHtml(node.label || node.id)}</span>
<span class="kg-node-type">${escapeHtml(node.node_type || 'node')}</span>
<span class="kg-node-score">${Math.round((node.score || 0) * 100)}%</span>
</div>
<div class="kg-node-id">id: ${escapeHtml(node.id)}</div>
<div class="kg-node-actions">
<button class="kg-use-btn" onclick="insertActivateFromKg('${escapeHtml(node.node_type || 'Entity')}', '${escapeHtml(node.label || node.id)}')">
Use in activate
</button>
<button class="kg-use-btn" style="color:var(--accent);border-color:rgba(56,189,248,0.3);background:rgba(56,189,248,0.08);" onclick="askNeuronAboutNode('${escapeHtml(node.label || node.id)}', '${escapeHtml(node.node_type || '')}')">
Ask Neuron
</button>
</div>
</div>
`).join('');
} catch (err) {
results.innerHTML = `<div style="color:var(--red);font-size:11px;">Error: ${escapeHtml(String(err))}</div>`;
}
}
window.insertActivateFromKg = (nodeType, nodeLabel) => {
const pos = editor.state.selection.main.head;
const snippet = `activate ${nodeType} where "${nodeLabel}"`;
editor.dispatch({ changes: { from: pos, insert: snippet } });
editor.focus();
showToast(`Inserted activate expression`);
};
window.askNeuronAboutNode = (label, nodeType) => {
sendNeuronMessage(`Tell me about the "${label}" ${nodeType} node in the knowledge graph and how I could use it in an activate query.`);
if (!neuronPanelVisible) toggleNeuronPanel();
};
// ═══════════════════════════════════════════════════════════════════════════
// Theming
// ═══════════════════════════════════════════════════════════════════════════
function applyTheme(name) {
const html = document.documentElement;
html.classList.remove('theme-dark', 'theme-light', 'theme-neuron', 'theme-high-contrast');
if (name !== 'dark') html.classList.add(`theme-${name}`);
localStorage.setItem('el-ide-theme', name);
// Update header theme menu active state
document.querySelectorAll('.theme-option').forEach(el =>
el.classList.toggle('active', el.dataset.theme === name)
);
// Update settings theme grid
document.querySelectorAll('.settings-theme-btn').forEach(el =>
el.classList.toggle('active', el.dataset.theme === name)
);
// Notify backend
fetch('/api/themes/active', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name }),
}).catch(() => {});
// Persist theme in settings
saveSettingsToApi({ theme: name });
// Redraw graph since canvas colors change
drawGraph();
}
// Header theme button
const btnTheme = document.getElementById('btn-theme');
const themeMenu = document.getElementById('theme-menu');
btnTheme.addEventListener('click', e => {
e.stopPropagation();
themeMenu.style.display = themeMenu.style.display === 'none' ? 'block' : 'none';
});
document.addEventListener('click', e => {
if (!btnTheme.contains(e.target) && !themeMenu.contains(e.target)) {
themeMenu.style.display = 'none';
}
});
document.querySelectorAll('.theme-option').forEach(el => {
el.addEventListener('click', () => {
applyTheme(el.dataset.theme);
themeMenu.style.display = 'none';
});
});
// Settings theme buttons
document.querySelectorAll('.settings-theme-btn').forEach(el => {
el.addEventListener('click', () => applyTheme(el.dataset.theme));
});
// ═══════════════════════════════════════════════════════════════════════════
// Settings
// ═══════════════════════════════════════════════════════════════════════════
const fontSizeSlider = document.getElementById('setting-font-size');
const fontSizeVal = document.getElementById('setting-font-size-val');
const tabSizeSelect = document.getElementById('setting-tab-size');
const wordWrapCheck = document.getElementById('setting-word-wrap');
// Word wrap compartment for dynamic reconfiguration
let wordWrapEnabled = false;
function applySetting(key, value) {
localStorage.setItem(`el-ide-setting-${key}`, value);
if (key === 'fontSize') {
document.getElementById('cm-editor').style.fontSize = value + 'px';
fontSizeVal.textContent = value + 'px';
}
if (key === 'wordWrap') {
wordWrapEnabled = value === '1' || value === true;
applyWordWrap(wordWrapEnabled);
}
}
function applyWordWrap(enable) {
// Toggle word wrap via CSS on the CM content
const cmContent = document.querySelector('.cm-content');
const cmScroller = document.querySelector('.cm-scroller');
if (cmContent) {
if (enable) {
cmContent.style.whiteSpace = 'pre-wrap';
cmContent.style.wordBreak = 'break-all';
} else {
cmContent.style.whiteSpace = 'pre';
cmContent.style.wordBreak = '';
}
}
if (cmScroller) {
cmScroller.style.overflowX = enable ? 'hidden' : 'auto';
}
}
fontSizeSlider.addEventListener('input', () => {
applySetting('fontSize', fontSizeSlider.value);
saveSettingsToApi({ fontSize: parseInt(fontSizeSlider.value, 10) });
});
tabSizeSelect.addEventListener('change', () => {
localStorage.setItem('el-ide-setting-tabSize', tabSizeSelect.value);
saveSettingsToApi({ tabSize: parseInt(tabSizeSelect.value, 10) });
});
wordWrapCheck.addEventListener('change', () => {
applySetting('wordWrap', wordWrapCheck.checked ? '1' : '0');
saveSettingsToApi({ wordWrap: wordWrapCheck.checked });
});
document.getElementById('setting-vim-mode').addEventListener('change', e => {
setVimMode(e.target.checked);
saveSettingsToApi({ vimMode: e.target.checked });
});
document.getElementById('setting-format-on-save').addEventListener('change', e => {
formatOnSave = e.target.checked;
localStorage.setItem('el-ide-setting-formatOnSave', formatOnSave ? '1' : '0');
saveSettingsToApi({ formatOnSave: e.target.checked });
});
function applySettingsObject(s) {
if (s.fontSize) {
fontSizeSlider.value = s.fontSize;
applySetting('fontSize', s.fontSize);
localStorage.setItem('el-ide-setting-fontSize', s.fontSize);
}
if (s.tabSize) {
tabSizeSelect.value = s.tabSize;
localStorage.setItem('el-ide-setting-tabSize', s.tabSize);
}
if (s.wordWrap !== undefined) {
wordWrapCheck.checked = !!s.wordWrap;
applySetting('wordWrap', s.wordWrap ? '1' : '0');
localStorage.setItem('el-ide-setting-wordWrap', s.wordWrap ? '1' : '0');
}
if (s.vimMode !== undefined && s.vimMode) {
document.getElementById('setting-vim-mode').checked = true;
setVimMode(true);
}
if (s.formatOnSave !== undefined && s.formatOnSave) {
document.getElementById('setting-format-on-save').checked = true;
formatOnSave = true;
}
if (s.theme) {
applyTheme(s.theme);
}
}
async function loadSettingsFromApi() {
try {
const resp = await fetch('/api/settings');
if (!resp.ok) return;
const s = await resp.json();
applySettingsObject(s);
} catch { /* fall through to localStorage */ }
}
// Debounced save to API
let _settingsSaveTimer = null;
function saveSettingsToApi(patch) {
clearTimeout(_settingsSaveTimer);
_settingsSaveTimer = setTimeout(async () => {
try {
await fetch('/api/settings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ settings: patch }),
});
} catch { /* silently ignore */ }
}, 500);
}
function restoreSettings() {
// Try localStorage as immediate fallback (before API call resolves)
const fs = localStorage.getItem('el-ide-setting-fontSize');
if (fs) {
fontSizeSlider.value = fs;
applySetting('fontSize', fs);
}
const ts = localStorage.getItem('el-ide-setting-tabSize');
if (ts) tabSizeSelect.value = ts;
const ww = localStorage.getItem('el-ide-setting-wordWrap');
if (ww) wordWrapCheck.checked = ww === '1';
const vm = localStorage.getItem('el-ide-setting-vimMode');
if (vm === '1') {
document.getElementById('setting-vim-mode').checked = true;
setVimMode(true);
}
const fos = localStorage.getItem('el-ide-setting-formatOnSave');
if (fos === '1') {
document.getElementById('setting-format-on-save').checked = true;
formatOnSave = true;
}
// Restore bottom panel height
const bh = localStorage.getItem('el-ide-bottom-h');
if (bh) {
const panel = document.getElementById('bottom-panel');
panel.style.height = bh + 'px';
panel.style.minHeight = bh + 'px';
}
// Restore file tree width
const tw = localStorage.getItem('el-ide-tree-w');
if (tw) {
const tree = document.getElementById('file-tree');
tree.style.width = tw + 'px';
tree.style.minWidth = '100px';
}
// Restore type-graph width
const gw = localStorage.getItem('el-ide-graph-w');
if (gw) {
const tg = document.getElementById('type-graph');
if (tg) {
tg.style.width = gw + 'px';
tg.style.minWidth = '120px';
tg.style.flex = 'none';
}
}
}
// ═══════════════════════════════════════════════════════════════════════════
// Terminal (xterm.js via CDN, lazy loaded)
// ═══════════════════════════════════════════════════════════════════════════
async function initTerminal() {
const container = document.getElementById('xterm-container');
if (xterm) {
// Already initialized — just fit
xterm.focus();
return;
}
// Lazy-load xterm.js CSS
if (!document.getElementById('xterm-css')) {
const link = document.createElement('link');
link.id = 'xterm-css';
link.rel = 'stylesheet';
link.href = 'https://esm.sh/xterm@5/css/xterm.css';
document.head.appendChild(link);
}
try {
const { Terminal } = await import('https://esm.sh/xterm@5');
const { FitAddon } = await import('https://esm.sh/xterm-addon-fit@0.8');
xterm = new Terminal({
theme: {
background: '#0a0a0a',
foreground: '#e8edf3',
cursor: '#38bdf8',
selection: 'rgba(56,189,248,0.2)',
},
fontFamily: 'DM Mono, monospace',
fontSize: 12,
cursorBlink: true,
scrollback: 1000,
});
const fitAddon = new FitAddon();
xterm.loadAddon(fitAddon);
xterm.open(container);
fitAddon.fit();
// Connect WebSocket
const wsProto = location.protocol === 'https:' ? 'wss' : 'ws';
xtermWs = new WebSocket(`${wsProto}://${location.host}/api/terminal`);
xtermWs.onopen = () => {
xterm.writeln('\x1b[32mConnected to shell\x1b[0m');
};
xtermWs.onmessage = (e) => {
xterm.write(e.data);
};
xtermWs.onclose = () => {
xterm.writeln('\r\n\x1b[31mConnection closed\x1b[0m');
};
xtermWs.onerror = () => {
xterm.writeln('\r\n\x1b[31mWebSocket error\x1b[0m');
};
xterm.onData(data => {
if (xtermWs && xtermWs.readyState === WebSocket.OPEN) {
xtermWs.send(data);
}
});
// Re-fit on resize
const resizeObs = new ResizeObserver(() => {
try { fitAddon.fit(); } catch {}
});
resizeObs.observe(container);
xtermLoaded = true;
xterm.focus();
} catch (err) {
container.innerHTML = `<div style="color:var(--red);padding:12px;font-family:monospace;font-size:12px;">Terminal error: ${escapeHtml(String(err))}</div>`;
}
}
// ═══════════════════════════════════════════════════════════════════════════
// Vim keybindings (lazy loaded from esm.sh)
// ═══════════════════════════════════════════════════════════════════════════
async function setVimMode(enable) {
vimEnabled = enable;
localStorage.setItem('el-ide-setting-vimMode', enable ? '1' : '0');
const indicator = document.getElementById('status-vim-mode');
if (enable) {
try {
const { vim } = await import('https://esm.sh/@replit/codemirror-vim@6');
// Reconfigure editor with vim extension
editor.dispatch({
effects: StateEffect.appendConfig.of(vim()),
});
indicator.textContent = 'VIM';
indicator.style.display = '';
const sep = document.createElement('span');
sep.className = 'status-sep';
sep.textContent = '|';
indicator.after(sep);
} catch (err) {
console.warn('Vim mode failed to load:', err);
}
} else {
// Can't easily remove extensions from CM6 without recreating the editor
// Show indicator change; full vim disable requires page reload
indicator.textContent = 'VIM OFF (reload to disable)';
indicator.style.display = '';
setTimeout(() => {
indicator.style.display = 'none';
}, 3000);
}
}
// ═══════════════════════════════════════════════════════════════════════════
// Document Outline
// ═══════════════════════════════════════════════════════════════════════════
async function refreshOutline() {
const source = editor.state.doc.toString();
const list = document.getElementById('outline-list');
if (!source.trim()) {
list.innerHTML = '<div style="color:var(--text3);font-size:11px;">No content.</div>';
return;
}
try {
const resp = await fetch(`/api/outline?source=${encodeURIComponent(source)}`);
if (!resp.ok) return;
const items = await resp.json();
if (!items.length) {
list.innerHTML = '<div style="color:var(--text3);font-size:11px;padding:8px 0;">No symbols found.</div>';
return;
}
list.innerHTML = items.map(item => `
<div class="outline-item" data-line="${item.line}">
<span class="outline-kind outline-kind-${item.kind}">${item.kind}</span>
<span class="outline-name">${escapeHtml(item.name)}</span>
<span class="outline-line">:${item.line}</span>
</div>
`).join('');
list.querySelectorAll('.outline-item').forEach(el => {
el.addEventListener('click', () => {
const lineNum = parseInt(el.dataset.line, 10);
try {
const lineInfo = editor.state.doc.line(lineNum);
editor.dispatch({ selection: { anchor: lineInfo.from }, scrollIntoView: true });
editor.focus();
} catch {}
});
});
} catch (err) {
list.innerHTML = `<div style="color:var(--red);font-size:11px;">Error: ${escapeHtml(String(err))}</div>`;
}
}
// ═══════════════════════════════════════════════════════════════════════════
// Format on save
// ═══════════════════════════════════════════════════════════════════════════
let formatOnSave = false;
async function formatCurrentFile() {
const tab = tabs.find(t => t.id === activeTabId);
if (!tab) return false;
const content = editor.state.doc.toString();
try {
const resp = await fetch('/api/format', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content, path: tab.path }),
});
if (!resp.ok) return false;
const { content: formatted, changed } = await resp.json();
if (changed) {
const cur = editor.state.selection.main.anchor;
editor.dispatch({
changes: { from: 0, to: editor.state.doc.length, insert: formatted },
selection: { anchor: Math.min(cur, formatted.length) },
});
tab.content = formatted;
}
return true;
} catch { return false; }
}
// ═══════════════════════════════════════════════════════════════════════════
// Resizable bottom panel
// ═══════════════════════════════════════════════════════════════════════════
(function initResizeHandle() {
const handle = document.getElementById('bottom-resize-handle');
const panel = document.getElementById('bottom-panel');
let dragging = false;
let startY = 0;
let startH = 0;
handle.addEventListener('mousedown', e => {
dragging = true;
startY = e.clientY;
startH = panel.offsetHeight;
document.body.style.cursor = 'ns-resize';
document.body.style.userSelect = 'none';
});
document.addEventListener('mousemove', e => {
if (!dragging) return;
const delta = startY - e.clientY;
const newH = Math.max(80, Math.min(window.innerHeight * 0.7, startH + delta));
panel.style.height = newH + 'px';
panel.style.minHeight = newH + 'px';
});
document.addEventListener('mouseup', () => {
if (!dragging) return;
dragging = false;
document.body.style.cursor = '';
document.body.style.userSelect = '';
// Save to localStorage
localStorage.setItem('el-ide-bottom-h', panel.offsetHeight);
});
})();
// ═══════════════════════════════════════════════════════════════════════════
// File tree resize + collapse
// ═══════════════════════════════════════════════════════════════════════════
let fileTreeCollapsed = false;
function toggleFileTree() {
const tree = document.getElementById('file-tree');
const handle = document.getElementById('file-tree-resize');
const btn = document.getElementById('btn-collapse-tree');
fileTreeCollapsed = !fileTreeCollapsed;
if (fileTreeCollapsed) {
tree.style.width = '0';
tree.style.minWidth = '0';
tree.style.overflow = 'hidden';
handle.style.display = 'none';
if (btn) btn.textContent = '';
} else {
const saved = localStorage.getItem('el-ide-tree-w') || '220';
tree.style.width = saved + 'px';
tree.style.minWidth = '100px';
tree.style.overflow = '';
handle.style.display = '';
if (btn) btn.textContent = '';
}
}
document.getElementById('btn-collapse-tree').addEventListener('click', () => toggleFileTree());
document.getElementById('btn-new-file').addEventListener('click', () => {
const name = prompt('New file name (relative to project root):');
if (name) createNewFile(name);
});
document.getElementById('btn-refresh-tree').addEventListener('click', () => {
loadFileTree().then(() => loadGitStatus());
showToast('File tree refreshed');
});
(function initFileTreeResize() {
const handle = document.getElementById('file-tree-resize');
const tree = document.getElementById('file-tree');
let dragging = false;
let startX = 0;
let startW = 0;
handle.addEventListener('mousedown', e => {
if (fileTreeCollapsed) return;
dragging = true;
startX = e.clientX;
startW = tree.offsetWidth;
document.body.style.cursor = 'ew-resize';
document.body.style.userSelect = 'none';
e.preventDefault();
});
document.addEventListener('mousemove', e => {
if (!dragging) return;
const newW = Math.max(100, Math.min(500, startW + (e.clientX - startX)));
tree.style.width = newW + 'px';
tree.style.minWidth = newW + 'px';
});
document.addEventListener('mouseup', () => {
if (!dragging) return;
dragging = false;
document.body.style.cursor = '';
document.body.style.userSelect = '';
localStorage.setItem('el-ide-tree-w', tree.offsetWidth);
});
})();
// ═══════════════════════════════════════════════════════════════════════════
// Type-graph right-panel resize
// ═══════════════════════════════════════════════════════════════════════════
(function initTypeGraphResize() {
// Insert a resize handle between editor-col and type-graph at runtime
const typeGraph = document.getElementById('type-graph');
const mainRow = document.getElementById('main-row');
if (!typeGraph || !mainRow) return;
// Create handle element between editor-col and type-graph
const handle = document.createElement('div');
handle.className = 'panel-resize-handle';
handle.id = 'type-graph-resize';
handle.style.cursor = 'ew-resize';
mainRow.insertBefore(handle, typeGraph);
let dragging = false;
let startX = 0;
let startW = 0;
handle.addEventListener('mousedown', e => {
dragging = true;
startX = e.clientX;
startW = typeGraph.offsetWidth;
document.body.style.cursor = 'ew-resize';
document.body.style.userSelect = 'none';
e.preventDefault();
});
document.addEventListener('mousemove', e => {
if (!dragging) return;
// Moving handle left increases type-graph width (handle is on the left of type-graph)
const newW = Math.max(120, Math.min(600, startW + (startX - e.clientX)));
typeGraph.style.width = newW + 'px';
typeGraph.style.minWidth = newW + 'px';
typeGraph.style.flex = 'none';
});
document.addEventListener('mouseup', () => {
if (!dragging) return;
dragging = false;
document.body.style.cursor = '';
document.body.style.userSelect = '';
localStorage.setItem('el-ide-graph-w', typeGraph.offsetWidth);
});
})();
// ═══════════════════════════════════════════════════════════════════════════
// Toggle bottom panel
// ═══════════════════════════════════════════════════════════════════════════
let bottomPanelVisible = true;
function toggleBottomPanel() {
const panel = document.getElementById('bottom-panel');
const handle = document.getElementById('bottom-resize-handle');
bottomPanelVisible = !bottomPanelVisible;
if (bottomPanelVisible) {
const saved = localStorage.getItem('el-ide-bottom-h') || '180';
panel.style.height = saved + 'px';
panel.style.minHeight = saved + 'px';
panel.style.display = '';
if (handle) handle.style.display = '';
} else {
localStorage.setItem('el-ide-bottom-h', panel.offsetHeight);
panel.style.display = 'none';
if (handle) handle.style.display = 'none';
}
}
// ═══════════════════════════════════════════════════════════════════════════
// Select next occurrence (Ctrl+D — add selection of next match)
// ═══════════════════════════════════════════════════════════════════════════
function selectNextOccurrence() {
if (!editor) return;
const state = editor.state;
const sel = state.selection;
const mainSel = sel.main;
let word;
if (mainSel.empty) {
// No selection — select word under cursor
const { from, to } = state.wordAt(mainSel.head) || { from: mainSel.head, to: mainSel.head };
word = state.doc.sliceString(from, to);
if (!word) return;
editor.dispatch({ selection: { anchor: from, head: to } });
return;
}
// Text already selected — find and add next occurrence
word = state.doc.sliceString(mainSel.from, mainSel.to);
if (!word) return;
const docText = state.doc.toString();
const searchFrom = mainSel.to;
let idx = docText.indexOf(word, searchFrom);
if (idx === -1) idx = docText.indexOf(word, 0); // wrap around
if (idx === -1 || idx === mainSel.from) return;
const { EditorSelection } = window._cm6State || {};
if (!EditorSelection) {
// Fallback: just move selection
editor.dispatch({ selection: { anchor: idx, head: idx + word.length }, scrollIntoView: true });
return;
}
const newSel = EditorSelection.create([
...sel.ranges,
EditorSelection.range(idx, idx + word.length),
], sel.ranges.length);
editor.dispatch({ selection: newSel, scrollIntoView: true });
}
// ═══════════════════════════════════════════════════════════════════════════
// Create new file
// ═══════════════════════════════════════════════════════════════════════════
async function createNewFile(filename) {
if (!filename) return;
try {
const resp = await fetch('/api/file', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: filename, content: '' }),
});
if (resp.ok) {
await loadFileTree();
openTab(filename, filename.split('/').pop(), '');
showToast(`Created ${filename}`);
} else {
showToast('Failed to create file');
}
} catch (e) {
showToast('Error creating file: ' + e.message);
}
}
// ═══════════════════════════════════════════════════════════════════════════
// Git Status — fetch changed files and annotate the file tree
// ═══════════════════════════════════════════════════════════════════════════
let gitStatus = {}; // path → status string
async function loadGitStatus() {
try {
const resp = await fetch('/api/git/status');
if (!resp.ok) return;
const files = await resp.json();
gitStatus = {};
for (const f of files) {
gitStatus[f.path] = f.status;
}
applyGitBadges();
} catch { /* silently ignore */ }
}
function applyGitBadges() {
// Remove any existing badges
document.querySelectorAll('.git-badge').forEach(b => b.remove());
for (const [path, status] of Object.entries(gitStatus)) {
const el = document.querySelector(`.file-item[data-path="${CSS.escape(path)}"]`);
if (!el) continue;
const badge = document.createElement('span');
badge.className = `git-badge git-badge-${status}`;
badge.textContent = status;
el.appendChild(badge);
}
}
// ═══════════════════════════════════════════════════════════════════════════
// Toast notification
// ═══════════════════════════════════════════════════════════════════════════
let toastTimer = null;
function showToast(msg, duration = 2000) {
const el = document.getElementById('toast');
el.textContent = msg;
el.classList.add('show');
clearTimeout(toastTimer);
toastTimer = setTimeout(() => el.classList.remove('show'), duration);
}
// ═══════════════════════════════════════════════════════════════════════════
// Utilities
// ═══════════════════════════════════════════════════════════════════════════
function escapeHtml(str) {
return String(str)
.replace(/&/g,'&amp;')
.replace(/</g,'&lt;')
.replace(/>/g,'&gt;')
.replace(/"/g,'&quot;');
}
function escapeRegex(str) {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
// ═══════════════════════════════════════════════════════════════════════════
// Breadcrumb — wire directory segment clicks to reveal in file tree
// ═══════════════════════════════════════════════════════════════════════════
document.getElementById('breadcrumb').addEventListener('click', e => {
const crumb = e.target.closest('.crumb');
if (!crumb) return;
const dirPath = crumb.dataset.path;
if (!dirPath) return;
// Expand/scroll to this directory in the file tree
const dirItem = document.querySelector(`.file-item.dir[data-path="${CSS.escape(dirPath)}"]`);
if (dirItem) {
dirItem.scrollIntoView({ block: 'nearest' });
// Toggle open if not already
if (!dirItem.classList.contains('open')) dirItem.click();
}
});
// ═══════════════════════════════════════════════════════════════════════════
// El version — fetch from /api/status
// ═══════════════════════════════════════════════════════════════════════════
async function loadElVersion() {
try {
const resp = await fetch('/api/status');
if (!resp.ok) return;
const s = await resp.json();
if (s.el_version) {
const verEl = document.getElementById('status-el-ver');
const sepEl = document.getElementById('status-el-ver-sep');
verEl.textContent = 'el ' + s.el_version;
verEl.title = 'El compiler ' + s.el_version;
if (sepEl) sepEl.style.display = '';
}
} catch { /* silently ignore */ }
}
// ═══════════════════════════════════════════════════════════════════════════
// Project config
// ═══════════════════════════════════════════════════════════════════════════
async function loadProjectConfig() {
try {
const resp = await fetch('/api/config');
if (!resp.ok) return;
const cfg = await resp.json();
document.getElementById('project-name-span').textContent = cfg.project_name;
} catch { /* silently ignore */ }
}
// ═══════════════════════════════════════════════════════════════════════════
// Init
// ═══════════════════════════════════════════════════════════════════════════
async function init() {
// Restore theme first to avoid flash
const savedTheme = localStorage.getItem('el-ide-theme') || 'dark';
applyTheme(savedTheme);
restoreSettings();
resizeCanvas();
window.addEventListener('resize', () => { resizeCanvas(); drawGraph(); });
// Load settings from server (may override localStorage values)
loadSettingsFromApi();
loadElVersion();
await Promise.all([loadProjectConfig(), loadFileTree(), loadServerSnippets()]);
loadGitStatus();
// Auto-open a sensible entry file
const candidates = [
'neuron-lang/memory.el',
'neuron-lang/neuron.el',
'neuron-lang/axon.el',
'src/main.el',
'main.el',
];
let opened = false;
for (const candidate of candidates) {
try {
const resp = await fetch(`/api/file?path=${encodeURIComponent(candidate)}`);
if (resp.ok) {
const { content } = await resp.json();
const name = candidate.split('/').pop();
openTab(candidate, name, content);
highlightFileInTree(candidate);
await refreshTypeGraph(content);
opened = true;
break;
}
} catch { /* try next */ }
}
if (!opened) {
const elItem = document.querySelector('.file-item:not(.dir)[data-path$=".el"]');
if (elItem) {
const path = elItem.dataset.path;
const name = path.split('/').pop();
openFileFromTree(path, name);
}
}
drawGraph();
// Load status
try {
const resp = await fetch('/api/status');
if (resp.ok) {
const s = await resp.json();
document.getElementById('project-name-span').textContent = s.project_name;
}
} catch {}
}
init();
</script>
</body>
</html>