This repository has been archived on 2026-04-03. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
core/src/js/repl.js
2026-03-14 16:46:16 +00:00

558 lines
20 KiB
JavaScript
Raw 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.
// ==========================================
// Chat Switchboard REPL Console
// ==========================================
// Fourth tab in the debug modal (Ctrl+Shift+L).
// Evaluates JavaScript with top-level await support
// via AsyncFunction. Injects app globals for introspection.
//
// Features:
// - AsyncFunction wrapper (top-level await)
// - Injected globals: API, Events, Extensions, DebugLog, Surfaces, Stores
// - Pretty-print results (collapsible JSON, red errors with stack)
// - Command history: ↑/↓ with sessionStorage
// - Tab-completion on live object graphs
// - Event label hints on Events.on(' / Events.emit('
// - Multi-line: Shift+Enter for newline, Enter to run
// - Admin-gated OR ?debug=1 URL param
// ==========================================
//
// Exports: window.REPL
const REPL = {
// ── State ────────────────────────────────
_history: [],
_historyIndex: -1,
_maxHistory: 100,
_storageKey: 'cs::repl::history',
_initialized: false,
// ── Initialization ───────────────────────
init() {
if (this._initialized) return;
this._initialized = true;
// Restore history from sessionStorage
try {
const saved = sessionStorage.getItem(this._storageKey);
if (saved) this._history = JSON.parse(saved);
} catch { /* ignore */ }
const input = document.getElementById('replInput');
if (!input) return;
input.addEventListener('keydown', (e) => this._onKeyDown(e));
input.addEventListener('input', () => this._autoResize(input));
// Gate: hide REPL tab if not admin and no ?debug=1
this._applyGate();
},
/**
* Hide the REPL tab unless user is admin or ?debug=1 is set.
*/
_applyGate() {
const tab = document.querySelector('.debug-tab[data-tab="repl"]');
if (!tab) return;
const isAdmin = typeof API !== 'undefined' && API.user?.role === 'admin';
const debugParam = new URLSearchParams(window.location.search).has('debug');
if (!isAdmin && !debugParam) {
tab.style.display = 'none';
} else {
tab.style.display = '';
}
},
// ── Execution ────────────────────────────
/**
* Evaluate an expression with top-level await support.
* Injects app globals as local variables.
*/
async run(code) {
if (!code.trim()) return;
// Add to history
this._history.push(code);
if (this._history.length > this._maxHistory) this._history.shift();
this._historyIndex = -1;
this._saveHistory();
// Render input
this._appendInput(code);
const startTime = performance.now();
try {
// Build an AsyncFunction with injected globals.
// The last expression is auto-returned if no explicit return.
const wrappedCode = this._wrapCode(code);
// Injected globals accessible inside the REPL
const globals = this._getGlobals();
const argNames = Object.keys(globals);
const argValues = Object.values(globals);
// eslint-disable-next-line no-new-func
const AsyncFunction = Object.getPrototypeOf(async function(){}).constructor;
const fn = new AsyncFunction(...argNames, wrappedCode);
const result = await fn(...argValues);
const elapsed = (performance.now() - startTime).toFixed(1);
if (result !== undefined) {
this._appendResult(result, elapsed);
} else {
this._appendInfo(`(undefined) [${elapsed}ms]`);
}
} catch (err) {
this._appendError(err);
}
this._scrollToBottom();
},
/**
* Wrap code for AsyncFunction evaluation.
* If the code is a single expression (no semicolons, no let/const/var/return),
* auto-return it. Otherwise, execute as-is.
*/
_wrapCode(code) {
const trimmed = code.trim();
// Multi-statement: check for declarations or multiple statements
if (/^(let |const |var |return |if |for |while |switch |try |class |function )/.test(trimmed)) {
return trimmed;
}
// Single expression — auto-return
// Handle edge case: object literals starting with { need wrapping
if (trimmed.startsWith('{') && !trimmed.startsWith('{(')) {
return `return (${trimmed})`;
}
return `return (${trimmed})`;
},
/**
* Get the set of globals injected into REPL scope.
*/
_getGlobals() {
const globals = {};
// Core app objects
if (typeof API !== 'undefined') globals.API = API;
if (typeof Events !== 'undefined') globals.Events = Events;
if (typeof Extensions !== 'undefined') globals.Extensions = Extensions;
if (typeof DebugLog !== 'undefined') globals.DebugLog = DebugLog;
if (typeof PanelRegistry !== 'undefined') globals.Panels = PanelRegistry;
if (typeof UI !== 'undefined') globals.UI = UI;
// Convenience aliases
globals.$ = (sel) => document.querySelector(sel);
globals.$$ = (sel) => [...document.querySelectorAll(sel)];
globals.sleep = (ms) => new Promise(r => setTimeout(r, ms));
return globals;
},
// ── Key Handling ─────────────────────────
_onKeyDown(e) {
const input = e.target;
// Enter (without Shift) → execute
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
this.run(input.value);
input.value = '';
this._autoResize(input);
return;
}
// ↑ / ↓ → history navigation (only when cursor is on first/last line)
if (e.key === 'ArrowUp' && this._isCursorOnFirstLine(input)) {
e.preventDefault();
this._navigateHistory(-1, input);
return;
}
if (e.key === 'ArrowDown' && this._isCursorOnLastLine(input)) {
e.preventDefault();
this._navigateHistory(1, input);
return;
}
// Tab → completion
if (e.key === 'Tab') {
e.preventDefault();
this._tabComplete(input);
return;
}
},
_isCursorOnFirstLine(input) {
return input.value.substring(0, input.selectionStart).indexOf('\n') === -1;
},
_isCursorOnLastLine(input) {
return input.value.substring(input.selectionStart).indexOf('\n') === -1;
},
_autoResize(input) {
input.style.height = 'auto';
input.style.height = Math.min(input.scrollHeight, 120) + 'px';
},
// ── History ──────────────────────────────
_navigateHistory(direction, input) {
if (this._history.length === 0) return;
if (this._historyIndex === -1) {
// Starting navigation — save current input
this._historyScratch = input.value;
this._historyIndex = this._history.length;
}
this._historyIndex += direction;
if (this._historyIndex < 0) this._historyIndex = 0;
if (this._historyIndex >= this._history.length) {
// Past end → restore scratch
this._historyIndex = -1;
input.value = this._historyScratch || '';
} else {
input.value = this._history[this._historyIndex];
}
this._autoResize(input);
// Move cursor to end
input.selectionStart = input.selectionEnd = input.value.length;
},
_saveHistory() {
try {
sessionStorage.setItem(this._storageKey, JSON.stringify(this._history));
} catch { /* quota exceeded — ignore */ }
},
// ── Tab Completion ──────────────────────
_tabComplete(input) {
const cursor = input.selectionStart;
const text = input.value.substring(0, cursor);
// Event label completion: Events.on(' or Events.emit('
const eventMatch = text.match(/Events\.(on|once|emit|off)\s*\(\s*['"]([^'"]*)?$/);
if (eventMatch) {
this._completeEventLabels(input, cursor, eventMatch[2] || '');
return;
}
// Object property completion: obj.prop or obj.prop.sub
const propMatch = text.match(/([\w$.]+)\.([\w]*)$/);
if (propMatch) {
this._completeProperty(input, cursor, propMatch[1], propMatch[2]);
return;
}
// Top-level global completion
const wordMatch = text.match(/([\w$]+)$/);
if (wordMatch) {
this._completeGlobal(input, cursor, wordMatch[1]);
}
},
_completeEventLabels(input, cursor, partial) {
// Gather known event labels from the Events handler map
if (typeof Events === 'undefined') return;
const labels = Array.from(Events._handlers?.keys() || []);
const matches = labels.filter(l => l.startsWith(partial)).sort();
if (matches.length === 0) return;
if (matches.length === 1) {
this._insertCompletion(input, cursor, partial, matches[0]);
} else {
this._appendInfo('Completions: ' + matches.join(', '));
// Complete common prefix
const common = this._commonPrefix(matches);
if (common.length > partial.length) {
this._insertCompletion(input, cursor, partial, common);
}
}
},
_completeProperty(input, cursor, objExpr, partial) {
try {
// Resolve the object in global scope
const globals = this._getGlobals();
// eslint-disable-next-line no-new-func
const obj = new Function(...Object.keys(globals), `return ${objExpr}`)(...Object.values(globals));
if (obj == null) return;
const keys = [];
// Own + prototype properties
for (let o = obj; o && o !== Object.prototype; o = Object.getPrototypeOf(o)) {
for (const k of Object.getOwnPropertyNames(o)) {
if (k.startsWith(partial) && !k.startsWith('_')) keys.push(k);
}
}
const unique = [...new Set(keys)].sort();
if (unique.length === 0) return;
if (unique.length === 1) {
this._insertCompletion(input, cursor, partial, unique[0]);
} else {
this._appendInfo('Completions: ' + unique.slice(0, 30).join(', ') + (unique.length > 30 ? ' …' : ''));
const common = this._commonPrefix(unique);
if (common.length > partial.length) {
this._insertCompletion(input, cursor, partial, common);
}
}
} catch { /* can't resolve — ignore */ }
},
_completeGlobal(input, cursor, partial) {
const globals = Object.keys(this._getGlobals());
// Add common window globals
const candidates = [...globals, 'document', 'window', 'console', 'fetch', 'JSON', 'Math',
'Array', 'Object', 'String', 'Number', 'Boolean', 'Promise', 'Map', 'Set'];
const matches = [...new Set(candidates)].filter(c => c.startsWith(partial)).sort();
if (matches.length === 0) return;
if (matches.length === 1) {
this._insertCompletion(input, cursor, partial, matches[0]);
} else {
this._appendInfo('Completions: ' + matches.join(', '));
const common = this._commonPrefix(matches);
if (common.length > partial.length) {
this._insertCompletion(input, cursor, partial, common);
}
}
},
_insertCompletion(input, cursor, partial, completion) {
const before = input.value.substring(0, cursor - partial.length);
const after = input.value.substring(cursor);
input.value = before + completion + after;
const newPos = before.length + completion.length;
input.selectionStart = input.selectionEnd = newPos;
},
_commonPrefix(strs) {
if (strs.length === 0) return '';
let prefix = strs[0];
for (let i = 1; i < strs.length; i++) {
while (!strs[i].startsWith(prefix)) {
prefix = prefix.slice(0, -1);
}
}
return prefix;
},
// ── Output Rendering ────────────────────
_appendInput(code) {
const output = document.getElementById('replOutput');
if (!output) return;
const div = document.createElement('div');
div.className = 'repl-entry repl-entry-input';
const pre = document.createElement('pre');
pre.textContent = '> ' + code;
div.appendChild(pre);
output.appendChild(div);
},
_appendResult(value, elapsed) {
const output = document.getElementById('replOutput');
if (!output) return;
const div = document.createElement('div');
div.className = 'repl-entry repl-entry-result';
const formatted = this._formatValue(value);
div.appendChild(formatted);
if (elapsed) {
const time = document.createElement('span');
time.className = 'repl-elapsed';
time.textContent = `[${elapsed}ms]`;
div.appendChild(time);
}
output.appendChild(div);
},
_appendError(err) {
const output = document.getElementById('replOutput');
if (!output) return;
const div = document.createElement('div');
div.className = 'repl-entry repl-entry-error';
const msg = document.createElement('pre');
msg.className = 'repl-error-msg';
msg.textContent = err.message || String(err);
div.appendChild(msg);
if (err.stack) {
const stack = document.createElement('pre');
stack.className = 'repl-error-stack';
stack.textContent = err.stack.split('\n').slice(1, 5).join('\n');
stack.style.display = 'none';
const toggle = document.createElement('button');
toggle.className = 'repl-stack-toggle';
toggle.textContent = '▸ stack';
toggle.onclick = () => {
const shown = stack.style.display !== 'none';
stack.style.display = shown ? 'none' : '';
toggle.textContent = shown ? '▸ stack' : '▾ stack';
};
div.appendChild(toggle);
div.appendChild(stack);
}
output.appendChild(div);
},
_appendInfo(text) {
const output = document.getElementById('replOutput');
if (!output) return;
const div = document.createElement('div');
div.className = 'repl-entry repl-entry-info';
div.textContent = text;
output.appendChild(div);
},
/**
* Format a JS value for display.
* Objects/arrays get collapsible JSON; primitives get plain text.
*/
_formatValue(value) {
if (value === null) return this._textEl('null', 'repl-null');
if (value === undefined) return this._textEl('undefined', 'repl-undefined');
const type = typeof value;
if (type === 'string') return this._textEl(JSON.stringify(value), 'repl-string');
if (type === 'number' || type === 'bigint') return this._textEl(String(value), 'repl-number');
if (type === 'boolean') return this._textEl(String(value), 'repl-boolean');
if (type === 'function') return this._textEl(`[Function: ${value.name || 'anonymous'}]`, 'repl-function');
if (type === 'symbol') return this._textEl(String(value), 'repl-symbol');
if (value instanceof HTMLElement) {
return this._textEl(`<${value.tagName.toLowerCase()}${value.id ? '#' + value.id : ''}${value.className ? '.' + value.className.split(' ').join('.') : ''}>`, 'repl-dom');
}
if (value instanceof Error) {
return this._textEl(`${value.constructor.name}: ${value.message}`, 'repl-error-msg');
}
// Objects and arrays → collapsible JSON
try {
const json = JSON.stringify(value, null, 2);
if (json.length < 200) {
return this._textEl(json, 'repl-json');
}
return this._collapsibleJson(json, value);
} catch {
return this._textEl(String(value), 'repl-object');
}
},
_textEl(text, className) {
const pre = document.createElement('pre');
pre.className = className || '';
pre.textContent = text;
return pre;
},
_collapsibleJson(json, value) {
const container = document.createElement('div');
container.className = 'repl-collapsible';
const summary = Array.isArray(value)
? `Array(${value.length})`
: `Object {${Object.keys(value).slice(0, 3).join(', ')}${Object.keys(value).length > 3 ? ', …' : ''}}`;
const toggle = document.createElement('button');
toggle.className = 'repl-json-toggle';
toggle.textContent = '▸ ' + summary;
const pre = document.createElement('pre');
pre.className = 'repl-json';
pre.textContent = json;
pre.style.display = 'none';
toggle.onclick = () => {
const shown = pre.style.display !== 'none';
pre.style.display = shown ? 'none' : '';
toggle.textContent = (shown ? '▸ ' : '▾ ') + summary;
};
container.appendChild(toggle);
container.appendChild(pre);
return container;
},
_scrollToBottom() {
const output = document.getElementById('replOutput');
if (output) output.scrollTop = output.scrollHeight;
},
// ── Toolbar Actions ──────────────────────
clear() {
const output = document.getElementById('replOutput');
if (output) output.innerHTML = '';
},
copyOutput() {
const output = document.getElementById('replOutput');
if (!output) return;
const text = output.innerText;
navigator.clipboard.writeText(text)
.then(() => { if (typeof showToast === 'function') showToast('📋 REPL output copied', 'success'); })
.catch(() => {});
},
showHelp() {
const globals = Object.keys(this._getGlobals());
const help = [
'╭─ REPL Help ─────────────────────────────╮',
'│ Enter Run expression │',
'│ Shift+Enter New line │',
'│ ↑ / ↓ Command history │',
'│ Tab Auto-complete │',
'╰──────────────────────────────────────────╯',
'',
'Injected globals: ' + globals.join(', '),
'',
'Top-level await is supported:',
' const r = await API._get("/api/v1/me")',
'',
'Shortcuts:',
' $(sel) → document.querySelector(sel)',
' $$(sel) → document.querySelectorAll(sel)',
' sleep(ms) → await sleep(1000)',
];
help.forEach(line => this._appendInfo(line));
this._scrollToBottom();
},
};
// Initialized from app.js startApp() after auth is confirmed,
// ensuring API.user.role is available for admin gate check.
// ── Exports ─────────────────────────────────
sb.ns('REPL', REPL);