GAL / UI /index.html
Clarkoer's picture
fix ui
2cab988
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="theme-color" content="#0d1b0f">
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate">
<meta http-equiv="Pragma" content="no-cache">
<meta http-equiv="Expires" content="0">
<title>Grow A Language</title>
<!-- Use local favicon image -->
<link rel="icon" type="image/png" href="/images/0.png" sizes="32x32">
<link rel="stylesheet" href="style.pixel.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/xterm/css/xterm.css" />
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Baloo+2:wght@400;600;700&family=Press+Start+2P&display=swap" rel="stylesheet">
</head>
<body>
<!-- IDE Title Bar -->
<div class="ide-titlebar">
<div class="ide-titlebar-left">
<div class="file-menu-wrap" id="file-menu-wrap">
<button class="ide-menu-btn" id="btn-file">File</button>
<div class="file-popup hidden" id="file-popup">
<button class="file-popup-item" id="fp-new">&#128196; New</button>
<button class="file-popup-item" id="fp-open">&#128194; Open</button>
<button class="file-popup-item" id="fp-save">&#128190; Save</button>
<div class="file-popup-divider"></div>
<button class="file-popup-item danger" id="fp-clear">&#128465; Clear</button>
</div>
</div>
</div>
<div class="ide-titlebar-center">
<img class="ide-center-logo" src="/images/0.png" alt="Grow A Language">
</div>
<div class="ide-titlebar-right">
<div class="mobile-lexeme-nav-toggle">
<button class="btn-toggle-lexemes" id="toggle-mobile-lexemes-nav" aria-expanded="true">
<span id="lexeme-toggle-icon">β–²</span> Lexemes
</button>
</div>
<button class="btn btn-chat-toggle" id="btn-chat-toggle" title="AI Chat Helper">
πŸͺ <span class="chat-btn-text">Ask</span>
</button>
<div class="run-dropdown">
<button id="btn-run" class="btn btn-primary ide-run-btn" onclick="runCode()" title="Run">
<span class="ide-run-icon">β–Ά</span> <span id="run-mode-text">Run</span>
</button>
<button class="btn btn-dropdown-toggle" onclick="toggleRunDropdown(event)">β–Ό</button>
<div class="run-dropdown-menu hidden" id="run-dropdown-menu">
<div class="run-dropdown-item" data-mode="lexer" onclick="selectRunMode('lexer')">Lexer</div>
<div class="run-dropdown-item" data-mode="syntax" onclick="selectRunMode('syntax')">Syntax</div>
<div class="run-dropdown-item" data-mode="semantic" onclick="selectRunMode('semantic')">Semantic</div>
<div class="run-dropdown-item" data-mode="run" onclick="selectRunMode('run')">Run</div>
</div>
</div>
</div>
</div>
<!-- IDE Tab Bar -->
<div class="ide-tabbar">
<div class="ide-tab active">
<span class="ide-tab-icon">πŸ“„</span>
<span class="current-file-name" id="current-file-name" title="Current file">untitled.gal</span>
<span class="unsaved-dot" id="unsaved-dot" title="Unsaved changes"></span>
</div>
<div class="ide-tabbar-fill"></div>
</div>
<!-- Workspace: sidebar + editor/tokens -->
<div class="workspace">
<aside class="sidebar" id="sidebar">
<div class="sd-section">LEXEMES</div>
<div class="side-tokens">
<table class="side-token-table" aria-label="Lexeme tokens live update">
<thead>
<tr>
<th>LEXEME</th>
<th>TOKEN</th>
<th>TYPE</th>
</tr>
</thead>
<tbody id="tokenBodySide"></tbody>
</table>
</div>
</aside>
<div class="sidebarResizer" title="Drag to resize lexeme panel"></div>
<div class="workspace-main">
<div class="mainCont">
<div class="textFieldCont">
<div id="editor" style="width: 100%; height: 100%;"></div>
</div>
<div class="widthResizer"></div>
<div class="outputCont">
<table class="tokenTable">
<thead>
<tr>
<th>LEXEME</th>
<th>TOKEN</th>
<th>TYPE</th>
</tr>
</thead>
<tbody id="tokenBody">
</tbody>
</table>
</div>
</div>
<div class="heightResizer" title="Drag to resize terminal"></div>
<div class="terminalCont">
<div class="terminal-header">
<div class="term-title">
<span class="dot dot-green" aria-hidden="true"></span>
OUTPUT
</div>
<div class="term-actions">
<button class="term-btn" id="term-autoscroll" aria-pressed="true" title="Toggle auto-scroll">Auto</button>
<button class="term-btn" id="term-font-dec" title="Decrease font size">-</button>
<button class="term-btn" id="term-font-inc" title="Increase font size">+</button>
<button class="term-btn" id="term-copy" title="Copy output">Copy</button>
<button class="term-btn danger" id="term-clear" title="Clear output">Clear</button>
</div>
</div>
<!-- Status chips moved to IDE status bar -->
<div class="terminal-body">
<div id="terminal"></div>
</div>
</div>
<!-- Mobile tokens backdrop -->
<div class="mobile-tokens-backdrop" id="mobile-tokens-backdrop"></div>
<!-- Mobile-only lexeme table placed under OUTPUT -->
<div class="mobile-tokens" aria-label="Lexemes (mobile)">
<div class="mobile-tokens-header">
<h3 class="mobile-tokens-title">LEXEME TABLE</h3>
<button class="mobile-tokens-close" id="mobile-tokens-close" aria-label="Close lexeme table">βœ•</button>
</div>
<table class="tokenTable">
<thead>
<tr>
<th>LEXEME</th>
<th>TOKEN</th>
<th>TYPE</th>
</tr>
</thead>
<tbody id="tokenBodyMobile"></tbody>
</table>
</div>
</div>
</div>
<div class="ide-statusbar">
<div class="ide-statusbar-left" role="status" aria-label="Analysis status">
<div class="status-chip" id="status-lex">Lexical: β€”</div>
<div class="status-chip" id="status-syn">Syntax: β€”</div>
<div class="status-chip" id="status-sem">Semantic: β€”</div>
<div class="status-chip" id="status-exe">Execution: β€”</div>
</div>
<div class="ide-statusbar-right">
<span class="ide-status-lang">GAL</span>
</div>
</div>
<!-- Mobile Status Popup (hidden on desktop) -->
<button class="mobile-status-fab" id="mobile-status-fab" aria-label="Show status">
<span class="mobile-status-fab-icon">●</span>
</button>
<div class="mobile-status-backdrop" id="mobile-status-backdrop"></div>
<div class="mobile-status-popup" id="mobile-status-popup">
<div class="mobile-status-header">
<h3 class="mobile-status-title">STATUS</h3>
<button class="mobile-status-close" id="mobile-status-close" aria-label="Close status">βœ•</button>
</div>
<div class="mobile-status-body">
<div class="mobile-status-chip" data-mirror="status-lex">Lexical: β€”</div>
<div class="mobile-status-chip" data-mirror="status-syn">Syntax: β€”</div>
<div class="mobile-status-chip" data-mirror="status-sem">Semantic: β€”</div>
<div class="mobile-status-chip" data-mirror="status-exe">Execution: β€”</div>
</div>
</div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/socket.io/4.0.1/socket.io.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/xterm/lib/xterm.min.js"></script> <!-- xterm.js -->
<script src="https://cdn.jsdelivr.net/npm/xterm-addon-fit/lib/xterm-addon-fit.min.js"></script> <!-- xterm addon fit -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/monaco-editor/0.43.0/min/vs/loader.js"></script> <!-- Monaco editor -->
<script src="main.js?v=20260331a"></script>
<script>
(function() {
var API = (location.port && location.port !== '5000') ? 'http://localhost:5000' : '';
var _inlineSock = null;
function getSock() {
if (window._galSocket && window._galSocket.connected) return window._galSocket;
if (_inlineSock && _inlineSock.connected) return _inlineSock;
try {
_inlineSock = io(API || window.location.origin, {
transports: ['websocket','polling'],
reconnection: true
});
console.log('[GAL] Created inline socket');
} catch(e) { console.error('[GAL] Socket create error:', e); }
return _inlineSock;
}
function tw(msg) {
var t = window._galTerm;
if (t && t.write) { t.write(msg); return; }
var el = document.getElementById('terminal');
if (el) {
var span = document.createElement('span');
span.textContent = msg.replace(/\r\n/g, '\n');
span.style.cssText = 'white-space:pre-wrap;color:#b6ffb6;font-family:monospace';
el.appendChild(span);
}
}
function setChip(id, cls, txt) {
var el = document.getElementById(id);
if (!el) return;
el.classList.remove('ok','err');
if (cls) el.classList.add(cls);
el.textContent = txt;
}
function resetChips() {
setChip('status-lex','','Lexical: \u2014');
setChip('status-syn','','Syntax: \u2014');
setChip('status-sem','','Semantic: \u2014');
setChip('status-exe','','Execution: \u2014');
}
function applyChips(stage, ok) {
if (stage==='lexical') { setChip('status-lex','err','Lexical: Error'); }
else if (stage==='syntax') { setChip('status-lex','ok','Lexical: OK'); setChip('status-syn','err','Syntax: Error'); }
else if (stage==='semantic') { setChip('status-lex','ok','Lexical: OK'); setChip('status-syn','ok','Syntax: OK'); setChip('status-sem','err','Semantic: Error'); }
else if (stage==='execution') { setChip('status-lex','ok','Lexical: OK'); setChip('status-syn','ok','Syntax: OK'); setChip('status-sem','ok','Semantic: OK'); setChip('status-exe', ok?'ok':'err', ok?'Execution: OK':'Execution: Error'); }
}
// Input/output handlers are managed by main.js (single persistent listeners).
// wireHandlers() removed to prevent duplicate listener registration.
// Override runCode β€” call global functions directly to avoid race conditions
function install() {
window.runCode = inlineRunCode;
console.log('[GAL] Inline run override v4 installed');
}
install();
// Re-install periodically in case main.js overwrites window.runCode
var _rc = setInterval(function() {
if (window.runCode !== inlineRunCode) {
window.runCode = inlineRunCode;
console.log('[GAL] Re-installed inline run override');
}
}, 500);
setTimeout(function() { clearInterval(_rc); }, 30000);
// ── Disable Run button when editor is empty ─────────────────────
// Polls until Monaco is ready, then hooks onDidChangeModelContent
// to toggle the btn-run disabled state based on whether the
// editor has any non-whitespace content.
function updateRunBtnState() {
var btn = document.getElementById('btn-run');
if (!btn) return;
var code = '';
try { code = monaco.editor.getModels()[0].getValue(); } catch(e) { return; }
var isEmpty = (code || '').trim() === '';
btn.disabled = isEmpty;
btn.title = isEmpty ? 'Editor is empty β€” nothing to run' : 'Run';
btn.style.opacity = isEmpty ? '0.45' : '';
btn.style.cursor = isEmpty ? 'not-allowed' : '';
}
var _btnWatch = setInterval(function() {
try {
var model = monaco.editor.getModels()[0];
if (!model) return;
clearInterval(_btnWatch);
model.onDidChangeContent(updateRunBtnState);
updateRunBtnState(); // initial state
} catch(e) { /* monaco not ready yet */ }
}, 200);
setTimeout(function() { clearInterval(_btnWatch); }, 30000);
async function inlineRunCode() {
// Safety net: even if the disabled state desyncs, refuse to run on empty input.
try {
var _src = monaco.editor.getModels()[0].getValue();
if (!_src || _src.trim() === '') {
var t = window._galTerm;
if (t) { t.write('\x1b[2J\x1b[3J\x1b[H'); t.write('\x1b[33mEditor is empty β€” nothing to run.\x1b[0m\r\n'); }
return;
}
} catch(e) { /* fall through to normal flow */ }
var modeEl = document.getElementById('run-mode-text');
var modeText = modeEl ? modeEl.textContent : '';
var term = window._galTerm;
// Lexer / Syntax / Semantic modes β€” call global functions directly
if (modeText.indexOf('Lexer') !== -1) {
if (term) { term.write('\x1b[2J\x1b[3J\x1b[H'); }
resetChips();
if (window._clearErrorHighlights) window._clearErrorHighlights();
if (window.runLexer) return window.runLexer({ silent: false });
console.warn('[GAL] runLexer not ready yet');
return;
}
if (modeText.indexOf('Syntax') !== -1) {
if (term) { term.write('\x1b[2J\x1b[3J\x1b[H'); }
resetChips();
if (window._clearErrorHighlights) window._clearErrorHighlights();
if (window.runSyntax) return window.runSyntax({ silent: false });
console.warn('[GAL] runSyntax not ready yet');
return;
}
if (modeText.indexOf('Semantic') !== -1) {
if (term) { term.write('\x1b[2J\x1b[3J\x1b[H'); }
resetChips();
if (window._clearErrorHighlights) window._clearErrorHighlights();
if (window.runSemantic) return window.runSemantic({ silent: false });
console.warn('[GAL] runSemantic not ready yet');
return;
}
var code = '';
try { code = monaco.editor.getModels()[0].getValue(); }
catch(e) { tw('Error: Editor not ready.\r\n'); return; }
if (term) { term.write('\x1b[2J\x1b[3J\x1b[H'); }
window._needsNewline = false;
resetChips();
// Clear previous error highlights immediately
if (window._clearErrorHighlights) window._clearErrorHighlights();
// Remove stale socket listeners
var sock0 = window._galSocket;
if (sock0) sock0.removeAllListeners('execution_complete');
// Always populate the lexeme table in the background
if (window.runLexer) await window.runLexer({ silent: true });
var hasWater = /\bwater\s*\(/.test(code);
if (!hasWater) {
// ---- REST path (simple, no input) ----
try {
var resp = await fetch(API + '/api/run', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ source_code: code })
});
var result = await resp.json();
if (result.output && result.output.length > 0) {
result.output.forEach(function(line) { tw(String(line) + '\r\n'); });
}
if (result.errors && result.errors.length > 0) {
result.errors.forEach(function(err) { tw('\x1b[31m' + String(err) + '\x1b[0m\r\n'); });
if (window._highlightErrors) window._highlightErrors(result.errors);
} else {
if (window._clearErrorHighlights) window._clearErrorHighlights();
}
applyChips(result.stage, result.success);
if (result.stage === 'execution') {
tw(result.success ? '\x1b[32mCode execution successful.\x1b[0m\r\n' : '\x1b[31mCode execution failed.\x1b[0m\r\n');
}
} catch(err) {
console.error('[GAL] REST error:', err);
tw('\x1b[31mError: Could not connect to server.\x1b[0m\r\n');
}
} else {
// ---- Socket.IO path (interactive water() support) ----
var sock = getSock();
if (!sock) { tw('\x1b[31mError: Socket not available.\x1b[0m\r\n'); return; }
// Reset input state from any previous run
if (window._resetInputState) window._resetInputState();
// Bump run generation and clear stale output
window._runGeneration = (window._runGeneration || 0) + 1;
if (window._socketOutputLog) window._socketOutputLog.length = 0;
// Remove any lingering execution_complete listeners from previous runs
sock.removeAllListeners('execution_complete');
// Ensure connected
if (!sock.connected) {
sock.connect();
await new Promise(function(resolve) {
if (sock.connected) return resolve();
sock.once('connect', resolve);
setTimeout(resolve, 4000);
});
}
if (!sock.connected) { tw('\x1b[31mError: Could not connect.\x1b[0m\r\n'); return; }
// Clear collected socket output before run
if (window._socketOutputLog) window._socketOutputLog.length = 0;
// One-time completion
sock.once('execution_complete', function(data) {
applyChips(data.stage, data.success);
if (data.stage === 'execution') {
tw(data.success ? '\r\n\x1b[32mCode execution successful.\x1b[0m\r\n' : '\r\n\x1b[31mCode execution failed.\x1b[0m\r\n');
}
// Highlight errors from collected socket output
if (!data.success && window._socketOutputLog && window._socketOutputLog.length > 0) {
if (window._highlightErrors) window._highlightErrors(window._socketOutputLog);
} else {
if (window._clearErrorHighlights) window._clearErrorHighlights();
}
});
// Run!
sock.emit('run_code', { source_code: code });
}
}
})();
</script>
<!-- File menu popup handlers -->
<script>
(function() {
function wireFileMenu() {
var btnFile = document.getElementById('btn-file');
var popup = document.getElementById('file-popup');
var fpNew = document.getElementById('fp-new');
var fpOpen = document.getElementById('fp-open');
var fpSave = document.getElementById('fp-save');
var fpClear = document.getElementById('fp-clear');
if (!btnFile || !popup || btnFile._galWired) return;
btnFile._galWired = true;
function closePopup() { popup.classList.add('hidden'); }
function togglePopup(e) { e.stopPropagation(); popup.classList.toggle('hidden'); }
btnFile.onclick = togglePopup;
document.addEventListener('click', function(e) {
if (!popup.contains(e.target) && e.target !== btnFile) closePopup();
});
var fileLabel = document.getElementById('current-file-name');
var unsavedDot = document.getElementById('unsaved-dot');
window._galFileHandle = null; // stored file handle for direct save
window._galDirty = false;
function setFileName(name) {
window._currentGalFile = name;
if (fileLabel) { fileLabel.textContent = name; fileLabel.title = name; }
}
function markDirty() {
if (!window._galDirty) {
window._galDirty = true;
if (unsavedDot) unsavedDot.classList.add('visible');
}
}
function markClean() {
window._galDirty = false;
if (unsavedDot) unsavedDot.classList.remove('visible');
}
window.markDirty = markDirty;
window.markClean = markClean;
// ── New ──
fpNew.onclick = function() {
closePopup();
if (window.editor && window.editor.getValue && window.editor.getValue().trim()) {
if (!confirm('Clear current code and start new?')) return;
}
window.location.reload();
};
// ── Open ──
fpOpen.onclick = async function() {
closePopup();
function clearOnOpen() {
var t = window._galTerm;
if (t) { t.reset(); t.clear(); }
if (window._clearErrorHighlights) window._clearErrorHighlights();
var tbody = document.getElementById('tokenBody');
if (tbody) tbody.innerHTML = '';
var tbodySide = document.getElementById('tokenBodySide');
if (tbodySide) tbodySide.innerHTML = '';
['status-lex','status-syn','status-sem','status-exe'].forEach(function(id) {
var el = document.getElementById(id);
if (el) { el.classList.remove('ok','err'); el.textContent = el.textContent.replace(/:.*/, ': \u2014'); }
});
}
// Use native file picker if available (Chrome/Edge) to get a reusable handle
if (window.showOpenFilePicker) {
try {
var handles = await window.showOpenFilePicker({
types: [{ description: 'GAL Source File', accept: { 'text/plain': ['.gal'] } }],
multiple: false
});
var handle = handles[0];
var file = await handle.getFile();
var text = await file.text();
window._galFileHandle = handle;
setFileName(file.name);
if (window.editor) window.editor.setValue(text);
clearOnOpen();
markClean();
return;
} catch (e) {
if (e.name === 'AbortError') return;
console.warn('[GAL] Open picker failed, using fallback:', e);
}
}
// Fallback
var input = document.createElement('input');
input.type = 'file';
input.accept = '.gal';
input.onchange = function() {
var file = input.files[0];
if (!file) return;
window._galFileHandle = null;
setFileName(file.name);
var reader = new FileReader();
reader.onload = function() {
if (window.editor) window.editor.setValue(reader.result);
clearOnOpen();
markClean();
};
reader.readAsText(file);
};
input.click();
};
// ── Save ──
fpSave.onclick = async function() {
closePopup();
var code = '';
if (window.editor && window.editor.getValue) code = window.editor.getValue();
if (!code) { alert('Editor is empty.'); return; }
// If we have an existing file handle, save directly to it
if (window._galFileHandle) {
try {
var writable = await window._galFileHandle.createWritable();
await writable.write(code);
await writable.close();
console.log('[GAL] Saved to', window._currentGalFile);
markClean();
return;
} catch (e) {
if (e.name === 'AbortError') return;
console.warn('[GAL] Direct save failed, falling through to Save As:', e);
}
}
// No handle β€” use Save As
if (window.showSaveFilePicker) {
try {
var handle = await window.showSaveFilePicker({
suggestedName: window._currentGalFile || 'untitled.gal',
types: [{ description: 'GAL Source File', accept: { 'text/plain': ['.gal'] } }]
});
window._galFileHandle = handle;
setFileName(handle.name);
var writable = await handle.createWritable();
await writable.write(code);
await writable.close();
markClean();
return;
} catch (e) {
if (e.name === 'AbortError') return;
}
}
var name = prompt('Save file as:', 'untitled.gal');
if (!name) return;
if (!name.endsWith('.gal')) name += '.gal';
var blob = new Blob([code], { type: 'text/plain' });
var url = URL.createObjectURL(blob);
var a = document.createElement('a');
a.href = url; a.download = name;
document.body.appendChild(a); a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
markClean();
};
// ── Clear ──
fpClear.onclick = function() {
closePopup();
if (window.editor) window.editor.setValue('');
if (window._clearErrorHighlights) window._clearErrorHighlights();
var t = window._galTerm;
if (t) { t.reset(); t.clear(); }
var tbody = document.getElementById('tokenBody');
if (tbody) tbody.innerHTML = '';
var tbodySide = document.getElementById('tokenBodySide');
if (tbodySide) tbodySide.innerHTML = '';
['status-lex','status-syn','status-sem','status-exe'].forEach(function(id) {
var el = document.getElementById(id);
if (el) { el.classList.remove('ok','err'); el.textContent = el.textContent.replace(/:.*/, ': \u2014'); }
});
};
console.log('[GAL] File menu wired');
}
wireFileMenu();
document.addEventListener('DOMContentLoaded', wireFileMenu);
setTimeout(wireFileMenu, 1000);
setTimeout(wireFileMenu, 3000);
})();
</script>
<!-- AI Chat Panel -->
<div class="chat-panel" id="chat-panel">
<div class="chat-header">
<div class="chat-header-left">
<span class="chat-title">🌱 GAL AI Assistant</span>
<span class="chat-subtitle">Compiler-aware assistant Β· Ready</span>
</div>
<div class="chat-header-actions">
<button class="chat-header-btn" id="chat-clear" title="Clear chat">πŸ—‘</button>
<button class="chat-header-btn" id="chat-close" title="Close chat">βœ•</button>
</div>
</div>
<div class="chat-messages" id="chat-messages">
<!-- Empty state watermark -->
<div class="chat-empty-state" id="chat-empty-state">
<div class="chat-watermark"><img src="/images/0.png" alt="GAL" class="chat-watermark-logo"></div>
<div class="chat-welcome-title">Hi! I'm your GAL AI Assistant.</div>
<div class="chat-welcome-sub">Ask me about:</div>
<div class="chat-welcome-topics">
<span>🌱 GAL syntax</span>
<span>πŸ› GAL Reserved words</span>
<span>✍️ Code writing</span>
<span>πŸ” Semantic issues</span>
</div>
<div class="chat-suggestion-chips" id="chat-suggestion-chips">
<button class="chat-chip" data-msg="What is GAL?">What is GAL?</button>
<button class="chat-chip" data-msg="What are the reserved words of GAL?">What are the reserved words of GAL?</button>
<button class="chat-chip" data-msg="Create a switch statement">Create a switch statement</button>
<button class="chat-chip" data-msg="Generate a GAL loop">Generate a GAL loop</button>
</div>
</div>
</div>
<div class="chat-input-area">
<div class="chat-context-hint" id="chat-context-hint">
<label><input type="checkbox" id="chat-include-code" checked> Include editor code as context</label>
</div>
<div class="chat-input-row">
<div class="chat-input-wrap">
<span class="chat-input-icon">🌱</span>
<textarea class="chat-input" id="chat-input" placeholder="Ask about GAL..." rows="1"></textarea>
</div>
<button class="chat-send-btn" id="chat-send" title="Send">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="22" y1="2" x2="11" y2="13"></line><polygon points="22 2 15 22 11 13 2 9 22 2"></polygon></svg>
</button>
</div>
</div>
</div>
<div class="chat-backdrop" id="chat-backdrop"></div>
<!-- AI Chat Script -->
<script>
(function() {
var API = (location.port && location.port !== '5000') ? 'http://localhost:5000' : '';
var chatPanel = document.getElementById('chat-panel');
var chatBackdrop = document.getElementById('chat-backdrop');
var chatMessages = document.getElementById('chat-messages');
var chatInput = document.getElementById('chat-input');
var chatSendBtn = document.getElementById('chat-send');
var chatToggleBtn = document.getElementById('btn-chat-toggle');
var chatCloseBtn = document.getElementById('chat-close');
var chatClearBtn = document.getElementById('chat-clear');
var chatIncludeCode = document.getElementById('chat-include-code');
var chatSessionId = 'session_' + Date.now();
var chatBusy = false;
function hideEmptyState() {
var es = document.getElementById('chat-empty-state');
if (es) es.remove();
}
function bindChips() {
var chips = document.querySelectorAll('.chat-chip');
chips.forEach(function(chip) {
chip.addEventListener('click', function() {
if (chatBusy) return;
chatInput.value = this.getAttribute('data-msg');
sendMessage();
});
});
}
bindChips();
function openChat() {
chatPanel.classList.add('open');
chatBackdrop.classList.add('show');
setTimeout(function() { chatInput.focus(); }, 300);
}
function closeChat() {
chatPanel.classList.remove('open');
chatBackdrop.classList.remove('show');
}
if (chatToggleBtn) chatToggleBtn.addEventListener('click', function() {
chatPanel.classList.contains('open') ? closeChat() : openChat();
});
if (chatCloseBtn) chatCloseBtn.addEventListener('click', closeChat);
if (chatBackdrop) chatBackdrop.addEventListener('click', closeChat);
function escapeHtml(str) {
var div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
function formatReply(text) {
// Extract code blocks into placeholders so \n→<br> doesn't corrupt them
var codeBlocks = [];
text = text.replace(/```(\w*)\n([\s\S]*?)```/g, function(_, lang, code) {
var label = lang || 'code';
var ph = '\x00CB' + codeBlocks.length + '\x00';
codeBlocks.push('<div class="code-block-wrap"><div class="code-block-header"><span class="code-block-lang">' + escapeHtml(label) + '</span><button class="chat-copy-btn" onclick="copyCodeBlock(this)">Copy</button></div><pre><code>' + escapeHtml(code.trim()) + '</code></pre></div>');
return ph;
});
// Inline code
text = text.replace(/`([^`]+)`/g, '<code>$1</code>');
// Bold
text = text.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
// Italic
text = text.replace(/\*([^*]+)\*/g, '<em>$1</em>');
// Line breaks (only outside code blocks now)
text = text.replace(/\n/g, '<br>');
// Restore code blocks
for (var ci = 0; ci < codeBlocks.length; ci++) {
text = text.replace('\x00CB' + ci + '\x00', codeBlocks[ci]);
}
return text;
}
// Detect message type tags from content
function detectTags(text) {
var tags = [];
var lower = text.toLowerCase();
if (/\bsyntax\b/.test(lower) || /\bparse\b/.test(lower) || /\bexpected\b.*\bgot\b/.test(lower))
tags.push({ label: 'Syntax', cls: 'tag-syntax' });
if (/\bsemantic\b/.test(lower) || /\bundeclared\b/.test(lower) || /\btype\s*(mis)?match\b/.test(lower))
tags.push({ label: 'Semantic', cls: 'tag-semantic' });
if (/```/.test(text))
tags.push({ label: 'Code Example', cls: 'tag-code' });
if (/\bfix\b/.test(lower) || /\bsolution\b/.test(lower) || /\binstead\b/.test(lower) || /\bcorrect\b/.test(lower))
tags.push({ label: 'Fix Suggestion', cls: 'tag-fix' });
if (tags.length === 0)
tags.push({ label: 'Info', cls: 'tag-info' });
return tags;
}
function buildTagsHtml(tags) {
return '<div class="chat-msg-tags">' + tags.map(function(t) {
return '<span class="chat-tag ' + t.cls + '">' + t.label + '</span>';
}).join('') + '</div>';
}
function buildActionsHtml() {
return '<div class="chat-actions">' +
'<button class="chat-action-btn" onclick="chatActionInsert(this)" title="Insert code into editor"><span class="action-icon">πŸ“‹</span>Insert into editor</button>' +
'</div>';
}
function addMessage(role, html) {
hideEmptyState();
var div = document.createElement('div');
div.className = 'chat-msg ' + role;
var avatar = document.createElement('div');
avatar.className = 'chat-avatar';
avatar.textContent = role === 'user' ? 'πŸ‘€' : '🌿';
var bubble = document.createElement('div');
bubble.className = 'chat-bubble';
bubble.innerHTML = html;
div.appendChild(avatar);
div.appendChild(bubble);
chatMessages.appendChild(div);
chatMessages.scrollTop = chatMessages.scrollHeight;
return div;
}
// Typewriter effect β€” streams formatted HTML word by word
function typewriterMessage(rawText, onDone, prefixHtml) {
var div = document.createElement('div');
div.className = 'chat-msg assistant';
var avatar = document.createElement('div');
avatar.className = 'chat-avatar';
avatar.textContent = '🌿';
var bubble = document.createElement('div');
bubble.className = 'chat-bubble';
// Insert prefix badge (e.g. fallback mode indicator) before content
if (prefixHtml) {
var prefixDiv = document.createElement('div');
prefixDiv.innerHTML = prefixHtml;
while (prefixDiv.firstChild) bubble.appendChild(prefixDiv.firstChild);
}
// Pre-compute tags and insert before content
var tags = detectTags(rawText);
var tagsDiv = document.createElement('div');
tagsDiv.innerHTML = buildTagsHtml(tags);
while (tagsDiv.firstChild) bubble.appendChild(tagsDiv.firstChild);
var contentWrap = document.createElement('div');
contentWrap.className = 'chat-bubble-content';
bubble.appendChild(contentWrap);
div.appendChild(avatar);
div.appendChild(bubble);
chatMessages.appendChild(div);
// Split raw text into streamable chunks (words/newlines/code-blocks kept whole)
var chunks = [];
var remaining = rawText;
// Pull out code blocks as single chunks, split the rest by word
var codeBlockRe = /```[\s\S]*?```/g;
var lastIdx = 0;
var m;
while ((m = codeBlockRe.exec(remaining)) !== null) {
if (m.index > lastIdx) {
var before = remaining.slice(lastIdx, m.index);
before.split(/(\s+)/).forEach(function(w) { if (w) chunks.push(w); });
}
chunks.push(m[0]); // whole code block
lastIdx = m.index + m[0].length;
}
if (lastIdx < remaining.length) {
remaining.slice(lastIdx).split(/(\s+)/).forEach(function(w) { if (w) chunks.push(w); });
}
var i = 0;
var accumulated = '';
var speed = 18; // ms per chunk
function tick() {
if (i >= chunks.length) {
contentWrap.innerHTML = formatReply(accumulated);
// Add action buttons
var actDiv = document.createElement('div');
actDiv.innerHTML = buildActionsHtml();
while (actDiv.firstChild) bubble.appendChild(actDiv.firstChild);
chatMessages.scrollTop = chatMessages.scrollHeight;
if (onDone) onDone();
return;
}
accumulated += chunks[i];
i++;
// Batch a few chunks at a time for smoother feel
var batch = 2;
while (batch-- > 0 && i < chunks.length && !/```/.test(chunks[i])) {
accumulated += chunks[i];
i++;
}
contentWrap.innerHTML = formatReply(accumulated);
chatMessages.scrollTop = chatMessages.scrollHeight;
requestAnimationFrame(function() { setTimeout(tick, speed); });
}
tick();
return div;
}
function addTyping() {
var div = document.createElement('div');
div.className = 'chat-msg assistant';
div.id = 'chat-typing-indicator';
div.innerHTML = '<div class="chat-avatar">🌿</div>'
+ '<div class="chat-thinking">'
+ '<div class="chat-thinking-status" id="think-status">'
+ '<span class="think-icon"></span>'
+ '<span class="think-text">Thinking...</span>'
+ '</div>'
+ '<div class="chat-thinking-progress"><div class="chat-thinking-progress-bar"></div></div>'
+ '</div>';
chatMessages.appendChild(div);
chatMessages.scrollTop = chatMessages.scrollHeight;
// Phase 2: change text after a delay
setTimeout(function() {
var statusEl = document.getElementById('think-status');
if (statusEl) {
var textEl = statusEl.querySelector('.think-text');
if (textEl) {
statusEl.classList.add('fading');
setTimeout(function() {
textEl.textContent = 'Analyzing your question...';
statusEl.classList.remove('fading');
}, 300);
}
}
}, 1200);
// Phase 3: another text change
setTimeout(function() {
var statusEl = document.getElementById('think-status');
if (statusEl) {
var textEl = statusEl.querySelector('.think-text');
if (textEl) {
statusEl.classList.add('fading');
setTimeout(function() {
textEl.textContent = 'Preparing response...';
statusEl.classList.remove('fading');
}, 300);
}
}
}, 2200);
}
function removeTyping() {
var el = document.getElementById('chat-typing-indicator');
if (el) el.remove();
}
function setChatBusy(busy) {
chatBusy = busy;
chatSendBtn.disabled = busy;
chatMessages.querySelectorAll('.chat-retry-btn').forEach(function(btn) {
btn.disabled = busy;
});
if (!busy) chatInput.focus();
}
function addFailureMessage(errorText, requestBody) {
var errorHtml = '<div class="chat-error-content">'
+ '<em class="chat-error-text">' + escapeHtml(errorText) + '</em>'
+ '<button class="chat-retry-btn" type="button" title="Retry message" aria-label="Retry message" disabled>'
+ '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M3 12a9 9 0 1 0 3-6.7"></path><path d="M3 4v6h6"></path></svg>'
+ '<span>Retry</span></button></div>';
var errorMessage = addMessage('assistant', errorHtml);
errorMessage.classList.add('chat-msg-error');
var retryBody = {
message: requestBody.message,
session_id: requestBody.session_id,
editor_code: requestBody.editor_code || ''
};
var retryButton = errorMessage.querySelector('.chat-retry-btn');
retryButton.addEventListener('click', function() {
if (chatBusy) return;
errorMessage.remove();
submitChatRequest(retryBody);
});
}
// Minimum thinking time so it feels deliberate (ms)
var _thinkStart = 0;
var THINK_MIN_MS = 1500;
async function submitChatRequest(body) {
setChatBusy(true);
_thinkStart = Date.now();
addTyping();
try {
var resp = await fetch(API + '/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
var data = await resp.json();
// Enforce minimum thinking time so fast responses still feel deliberate
var elapsed = Date.now() - _thinkStart;
var wait = Math.max(0, THINK_MIN_MS - elapsed);
await new Promise(function(r) { setTimeout(r, wait); });
removeTyping();
// Update mode indicator in subtitle
var subtitle = document.querySelector('.chat-subtitle');
if (data.mode === 'fallback') {
if (subtitle) subtitle.textContent = 'Offline mode Β· Rule-based';
if (subtitle) subtitle.style.color = '#f5c842';
} else {
if (subtitle) subtitle.textContent = 'Compiler-aware assistant Β· Ready';
if (subtitle) subtitle.style.color = '';
}
if (data.error) {
addFailureMessage(data.error, body);
setChatBusy(false);
} else {
var modePrefix = data.mode === 'fallback'
? '<div class="chat-fallback-badge">⚠ Offline mode β€” AI unavailable, using built-in knowledge base</div>'
: '';
typewriterMessage(data.reply || 'No response.', function() {
setChatBusy(false);
}, modePrefix);
}
} catch(err) {
removeTyping();
addFailureMessage('Could not connect to server.', body);
setChatBusy(false);
}
}
function sendMessage() {
if (chatBusy) return;
var msg = chatInput.value.trim();
if (!msg) return;
chatInput.value = '';
chatInput.style.height = 'auto';
addMessage('user', escapeHtml(msg));
var body = {
message: msg,
session_id: chatSessionId,
editor_code: ''
};
if (chatIncludeCode && chatIncludeCode.checked && window.editor) {
try { body.editor_code = window.editor.getValue(); } catch(e) {}
}
submitChatRequest(body);
}
if (chatSendBtn) chatSendBtn.addEventListener('click', sendMessage);
if (chatInput) {
chatInput.addEventListener('keydown', function(e) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendMessage();
}
});
// Auto-resize textarea
chatInput.addEventListener('input', function() {
this.style.height = 'auto';
this.style.height = Math.min(this.scrollHeight, 120) + 'px';
});
}
// Clear chat
if (chatClearBtn) chatClearBtn.addEventListener('click', async function() {
// Reset UI
chatMessages.innerHTML = document.getElementById('chat-panel').querySelector('.chat-empty-state') ? '' : '';
// Re-insert the empty state
var emptyState = document.createElement('div');
emptyState.className = 'chat-empty-state';
emptyState.id = 'chat-empty-state';
emptyState.innerHTML = '<div class="chat-watermark"><img src="/images/0.png" alt="GAL" class="chat-watermark-logo"></div><div class="chat-welcome-title">Hi! I\'m your GAL AI Assistant.</div><div class="chat-welcome-sub">Ask me about:</div><div class="chat-welcome-topics"><span>🌱 GAL syntax</span><span>πŸ› Debugging errors</span><span>✍️ Code writing</span><span>πŸ” Semantic issues</span></div><div class="chat-suggestion-chips" id="chat-suggestion-chips"><button class="chat-chip" data-msg="What is GAL?">What is GAL?</button><button class="chat-chip" data-msg="What are the reserved words of GAL?">What are the reserved words of GAL?</button><button class="chat-chip" data-msg="Create a switch statement">Create a switch statement</button><button class="chat-chip" data-msg="Generate a GAL loop">Generate a GAL loop</button></div>';
chatMessages.appendChild(emptyState);
bindChips();
// Reset server session
chatSessionId = 'session_' + Date.now();
try {
await fetch(API + '/api/chat/clear', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ session_id: chatSessionId })
});
} catch(e) {}
});
})();
// Global: copy code block content
function copyCodeBlock(btn) {
var wrap = btn.closest('.code-block-wrap');
var pre = wrap ? wrap.querySelector('pre') : btn.parentElement.querySelector('pre');
if (!pre) return;
var text = pre.textContent || pre.innerText;
navigator.clipboard.writeText(text).then(function() {
btn.textContent = 'Copied!';
btn.classList.add('copied');
setTimeout(function() {
btn.textContent = 'Copy';
btn.classList.remove('copied');
}, 1500);
});
}
// Action button handlers
function chatActionInsert(btn) {
// Find all code blocks in this message
var msg = btn.closest('.chat-msg');
if (!msg || !window.editor) return;
var codeEls = msg.querySelectorAll('pre code');
if (codeEls.length === 0) return;
// Gather all code snippets
var code = Array.from(codeEls).map(function(el) { return el.textContent; }).join('\n\n');
var cur = window.editor.getValue();
window.editor.setValue(cur ? cur + '\n\n' + code : code);
btn.innerHTML = '<span class="action-icon">βœ…</span>Inserted!';
setTimeout(function() { btn.innerHTML = '<span class="action-icon">πŸ“‹</span>Insert into editor'; }, 1500);
}
function chatActionSimplify(btn) {
var msg = btn.closest('.chat-msg');
if (!msg) return;
var bubble = msg.querySelector('.chat-bubble-content');
var text = bubble ? bubble.textContent.substring(0, 200) : '';
var chatInput = document.getElementById('chat-input');
if (chatInput) {
chatInput.value = 'Can you simplify this explanation? "' + text.trim() + '..."';
chatInput.dispatchEvent(new Event('input'));
chatInput.focus();
}
}
function chatActionRegenerate(btn) {
// Find the user message that preceded this assistant message
var msg = btn.closest('.chat-msg');
if (!msg) return;
var prev = msg.previousElementSibling;
while (prev && !prev.classList.contains('user')) prev = prev.previousElementSibling;
if (!prev) return;
var userBubble = prev.querySelector('.chat-bubble');
if (!userBubble) return;
var chatInput = document.getElementById('chat-input');
if (chatInput) {
chatInput.value = userBubble.textContent.trim();
chatInput.dispatchEvent(new Event('input'));
chatInput.focus();
// Remove the old assistant message
msg.remove();
}
}
</script>
</body>
</html>