// ========================================== // 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);