// Extracted from newOS4 index.html — WebX11Terminal
class WebX11SecurityManager {
constructor(terminal, level = 1) {
this.terminal = terminal;
this.level = level;
this.commandMeta = new Map();
this.lastRouteTime = 0;
this.lastRouteCmd = '';
this.blockedCount = 0;
this.destructiveCmds = new Set(['delete','del','write','save','overwrite','format','kill','stop','shutdown','reboot']);
this.systemCmds = new Set(['reboot','shutdown','format','kill','halt','poweroff']);
this.suspiciousFlags = new Set(['--dump','--execute','--eval','--exec','--shell','--script']);
this.whitelist = new Set(['help','clear','ls','cat','pwd','whoami','df','free','top','ps','echo','date','env','history']);
// Load persisted overrides from localStorage
try { const d = localStorage.getItem('webx11_security_destructive'); if(d){JSON.parse(d).forEach(c=>this.destructiveCmds.add(c));} }catch(e){}
try { const s = localStorage.getItem('webx11_security_system'); if(s){JSON.parse(s).forEach(c=>this.systemCmds.add(c));} }catch(e){}
try { const w = localStorage.getItem('webx11_security_whitelist'); if(w){JSON.parse(w).forEach(c=>this.whitelist.add(c));} }catch(e){}
try { const f = localStorage.getItem('webx11_security_flags'); if(f){JSON.parse(f).forEach(c=>this.suspiciousFlags.add(c));} }catch(e){}
this._registerBuiltIn();
}
_registerBuiltIn() {
const internals = ['clear','help','ls','cat','df','free','env','history','cd','pwd','touch','mkdir','rm','cp','mv','edit','cat','run','font-size','load-local','loadlocal','mem','security','security-level','security-destructive','security-system','security-whitelist','security-flag','config','ai-config','startup-modules','smart-prompt','cmd-allow','cmd-deny','system-health','server','servers','server-check','monitor','vfs','vfs-clean','vfs-size','empty-trash'];
internals.forEach(c => this.registerCommand(c, { category: 'builtin' }));
}
setLevel(n) {
if (n >= 0 && n <= 3) {
this.level = n;
this.terminal.print(`Security level set to ${n} (${this.getLevelName()})`, 'success');
if (n === 0) {
this.terminal.print('Warning: Permissive mode — commands will not be blocked or confirmed.', 'warning');
}
}
else { this.terminal.print('Level must be 0-3', 'error'); }
}
getLevel() { return this.level; }
getLevelName() {
return ['Permissive','Standard','Paranoid','Audit'][this.level] || 'Unknown';
}
registerCommand(name, meta = {}) {
this.commandMeta.set(name, { destructive: false, category: 'user', ...meta });
}
_collectAllCommands() {
const cmds = new Set([...this.commandMeta.keys()]);
for (const modName in this.terminal.moduleManager.modules) {
const mod = this.terminal.moduleManager.modules[modName];
if (mod.commands) {
for (const c in mod.commands) cmds.add(c);
}
}
const builtins = ['clear','help','ls','df','security','security-level'];
builtins.forEach(c => cmds.add(c));
return [...cmds];
}
_levenshtein(a, b) {
a = a.toLowerCase(); b = b.toLowerCase();
const m = a.length, n = b.length;
if (!m) return n; if (!n) return m;
let prev = new Uint16Array(n + 1);
let curr = new Uint16Array(n + 1);
for (let j = 0; j <= n; j++) prev[j] = j;
for (let i = 1; i <= m; i++) {
curr[0] = i;
for (let j = 1; j <= n; j++) {
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
curr[j] = Math.min(prev[j] + 1, curr[j - 1] + 1, prev[j - 1] + cost);
}
[prev, curr] = [curr, prev];
}
return prev[n];
}
suggest(input, maxDist = 2) {
const all = this._collectAllCommands();
const lower = input.toLowerCase();
let best = '', bestDist = Infinity;
for (const c of all) {
const d = this._levenshtein(lower, c);
if (d < bestDist && d <= maxDist) { bestDist = d; best = c; }
}
return best ? { cmd: best, dist: bestDist } : null;
}
_parseArgs(input) {
const args = [];
let current = '';
let inDouble = false;
let inSingle = false;
for (const ch of input) {
if (ch === '"' && !inSingle) { inDouble = !inDouble; continue; }
if (ch === "'" && !inDouble) { inSingle = !inSingle; continue; }
if (ch === ' ' && !inDouble && !inSingle) {
if (current) { args.push(current); current = ''; }
} else {
current += ch;
}
}
if (current) args.push(current);
return args;
}
_classify(raw) {
const parts = this._parseArgs(raw.trim());
const cmd = parts[0].toLowerCase();
const args = parts.slice(1);
// Internal commands always allowed at terminal level, but security still classifies
const all = this._collectAllCommands();
const known = all.includes(cmd);
const hasSuspicious = args.some(a => this.suspiciousFlags.has(a.toLowerCase()));
const destructive = this.destructiveCmds.has(cmd) || hasSuspicious;
const system = this.systemCmds.has(cmd);
const unknown = !known;
return { cmd, args, known, destructive, system, unknown, hasSuspicious };
}
async checkAndRun(rawInput) {
const { cmd, args, known, destructive, system, unknown, hasSuspicious } = this._classify(rawInput);
// Level 3 Audit: just log and allow
if (this.level === 3) {
this.terminal.print(`[AUDIT] ${rawInput} → known=${known} destructive=${destructive} system=${system}`, 'info');
return this._dispatch(cmd, args);
}
// Always allow builtins that are safe, regardless of level
if (['help','security','security-level'].includes(cmd)) {
return this._dispatch(cmd, args);
}
// Unknown command handling — skip at level 0 (permissive)
if (unknown && !this.whitelist.has(cmd)) {
if (this.level === 0) {
return this._dispatch(cmd, args);
}
this.blockedCount++;
const sug = this.suggest(cmd);
if (sug) {
this.terminal.print(`Security: Unknown command "${cmd}". Did you mean: "${sug.cmd}"?`, 'warning');
} else {
this.terminal.print(`Security: Unknown command "${cmd}" — blocked. Use 'security-level 0' to disable.`, 'warning');
}
// At level 1: suggest/fix but still route to AI (terminal is also a chat interface)
// At level 2+: block entirely (paranoid)
if (this.level >= 2) {
this.terminal.print(`Security: Unknown command "${cmd}" blocked (level ${this.level}).`, 'error');
return;
}
if (this.level >= 1) {
if (sug) {
this.terminal.print(`Security: Did you mean "${sug.cmd}"? Routing to AI...`, 'warning');
} else {
this.terminal.print(`Security: Routing to AI...`, 'info');
}
}
return this._tryAIRoute(rawInput);
}
// Suspicious flags (e.g. --execute) at any level > 0
if (hasSuspicious && this.level > 0) {
this.terminal.print(`Security: Suspicious flag detected in "${rawInput}" — blocked.`, 'error');
this.blockedCount++;
return;
}
// Destructive commands
if (destructive) {
if (this.level === 0) {
this.terminal.print(`Warning: destructive command "${cmd}"`, 'warning');
return this._dispatch(cmd, args);
}
if (this.level >= 1) {
// Reuse your existing confirm UI if present, otherwise print confirm message
if (this.terminal.showConfirmationBar) {
this.terminal._pendingCommand = () => this._dispatch(cmd, args);
this.terminal.showConfirmationBar(`Run destructive: ${rawInput}`);
return;
} else {
this.terminal.print(`Security: Destructive command "${cmd}" requires confirmation.`, 'warning');
return;
}
}
}
// System commands at level >=1
if (system && this.level >= 1) {
this.terminal.print(`Security: System command "${cmd}" blocked (level ${this.level}).`, 'error');
this.blockedCount++;
return;
}
// Standard dispatch for everything else
return this._dispatch(cmd, args);
}
async _dispatch(cmd, args) {
return this.terminal.dispatchCommand(cmd, args);
}
async _tryAIRoute(rawInput) {
// AI routing cooldown guard
const now = Date.now();
if (this.lastRouteCmd === rawInput && (now - this.lastRouteTime) < 5000) {
this.terminal.print(`Security: Already routed "${rawInput}" to AI recently. Use 'security-level 0' to force.`, 'warning');
return;
}
this.lastRouteCmd = rawInput;
this.lastRouteTime = now;
try {
const aiMod = this.terminal.moduleManager.modules['WebX11AIAssistantModule'];
if (aiMod) {
this.terminal.print('→ Routing to AI assistant...', 'info');
await aiMod.chat([rawInput.trim()]);
}
} catch (e) {
this.terminal.print(`Security: AI error: ${e.message}`, 'error');
}
}
}
class WebX11Terminal {
constructor() {
this.outputContainer = document.getElementById('terminal-container');
this.inputField = document.getElementById('command-input');
this.dropdown = document.getElementById('autocomplete-dropdown');
this.statusBar = document.getElementById('status-text');
this.networkStatus = document.getElementById('network-status');
this.fs = new VirtualFileSystem();
this.secretVault = null;
try {
if (typeof SecretVault !== 'undefined') {
this.secretVault = new SecretVault(this);
window.webx11_secretVault = this.secretVault;
}
} catch (e) { console.warn('SecretVault init:', e.message); }
this.moduleManager = new ModuleManager(this);
this.security = new WebX11SecurityManager(this, parseInt(localStorage.getItem('webx11_security_level')) || 1);
this.ollamaManager = new OllamaManager(this);
// Config registry — modules can register keys for the unified `config` command
this._configRegistry = {};
this._configDefaults = {};
this._configSetters = {};
this._loadTerminalConfig();
// Register built-in terminal config keys
this.registerConfigItem('terminal-max-lines', 200, (v) => { this.MAX_OUTPUT_LINES = v; });
this.registerConfigItem('terminal-hist-output', 50, (v) => { this._historyMaxLines = v; });
this.registerConfigItem('terminal-cmd-history', 200, (v) => { /* applied on next save */ });
this.registerConfigItem('terminal-pipe-buffer', 5000, (v) => { this._pipeBufferSize = v; });
this._pipeInput = null;
this._pipeBufferSize = 5000;
this._historyBuffer = [];
this._historyMaxLines = 50;
this._historySavePending = false;
this.MAX_OUTPUT_LINES = 200;
this._historyCap = 200;
this._aiBusy = false;
this._dirty = false;
this.history = JSON.parse(localStorage.getItem('webx11_command_history') || '[]');
this.historyIndex = this.history.length;
this.selectedSuggestion = -1;
this.suggestions = [];
this._isCdAutocomplete = false;
this._autocompletePrefix = '';
this._welcomeShown = false;
this.initEventListeners();
this.updateNetworkStatus();
this.loadBuiltInModules();
this._loadHistory();
// Auto-restore session
this._autoRestoreSession();
this._restoreFontSize();
// Restore saved theme on boot
this._restoreTheme();
// Init auto-save timer — 60s after boot, then every 60s
setTimeout(() => this._initAutoSave(), 30000);
// Init sound toggle button text
const soundBtn = document.getElementById('sound-toggle-btn');
if (soundBtn) {
soundBtn.textContent = localStorage.getItem('webx11_config_ai_sound') === 'off' ? '🔇 Muted' : '🔊 Sound';
}
setTimeout(() => this.inputField.focus(), 100);
// First-run welcome check — fires after ext modules are loaded
setTimeout(() => this._checkFirstRun(), 2000);
// Init all registered modules (safe now — constructor is done)
this._initRegisteredModules();
// Apply config overrides after everything is initialized
this._applyTerminalConfig();
}
initEventListeners() {
this.inputField.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
if (this.selectedSuggestion >= 0 && this.suggestions[this.selectedSuggestion]) {
if (this._autocompletePrefix) {
this.inputField.value = this._autocompletePrefix + this.suggestions[this.selectedSuggestion];
} else if (!this._isCdAutocomplete) {
this.inputField.value = this.suggestions[this.selectedSuggestion];
}
}
const cmd = this.inputField.value;
// Dedup: don't push if same as last entry
if (this.history.length === 0 || this.history[this.history.length - 1] !== cmd) {
this.history.push(cmd);
}
// Trim to max 200 entries
if (this.history.length > this._historyCap) {
this.history = this.history.slice(-this._historyCap);
}
this.historyIndex = this.history.length;
this._saveCommandHistory();
this.executeCommand(cmd);
this.inputField.value = '';
this.inputField.style.height = 'auto';
this.hideAutocomplete();
} else if (e.key === 'ArrowUp') {
e.preventDefault();
if (this.historyIndex > 0) {
this.historyIndex--;
this.inputField.value = this.history[this.historyIndex];
}
this.hideAutocomplete();
} else if (e.key === 'ArrowDown') {
e.preventDefault();
if (this.historyIndex < this.history.length - 1) {
this.historyIndex++;
this.inputField.value = this.history[this.historyIndex];
} else {
this.historyIndex = this.history.length;
this.inputField.value = '';
}
this.hideAutocomplete();
} else if (e.key === 'Tab') {
e.preventDefault();
if (this.suggestions.length > 0) {
this.selectedSuggestion = (this.selectedSuggestion + 1) % this.suggestions.length;
this.updateAutocompleteDisplay();
}
} else if (e.ctrlKey && (e.key === 'k' || e.key === 'K')) {
e.preventDefault();
this.toggleCmdCards();
} else if (e.key === 'Escape') {
if (this._aiBusy) {
this.executeCommand('ai-stop');
} else {
this.hideAutocomplete();
}
}
});
this.inputField.addEventListener('input', () => {
this.inputField.style.height = 'auto';
this.inputField.style.height = Math.min(this.inputField.scrollHeight, 120) + 'px';
this.showAutocomplete();
});
this.inputField.addEventListener('focus', () => {
this.showAutocomplete();
});
document.addEventListener('click', (e) => {
if (!e.target.closest('#input-area')) {
this.hideAutocomplete();
}
});
window.addEventListener('online', () => this.updateNetworkStatus());
window.addEventListener('offline', () => this.updateNetworkStatus());
document.getElementById('file-upload-input').addEventListener('change', (e) => {
const file = e.target.files[0];
if (file) {
const reader = new FileReader();
reader.onload = (evt) => {
this.fs.writeFile(file.name, evt.target.result);
this.markDirty();
this.print(`File '${file.name}' uploaded successfully.`, 'success');
};
reader.readAsText(file);
}
e.target.value = '';
});
// Voice mic button
// Sound toggle button
const soundBtn = document.getElementById('sound-toggle-btn');
if (soundBtn) {
soundBtn.addEventListener('click', () => {
const current = localStorage.getItem('webx11_config_ai_sound');
if (current === 'off') {
localStorage.setItem('webx11_config_ai_sound', 'on');
soundBtn.textContent = '🔊 Sound';
} else {
localStorage.setItem('webx11_config_ai_sound', 'off');
soundBtn.textContent = '🔇 Muted';
}
});
}
// Mic toggle button
const micToggleBtn = document.getElementById('voice-toggle-btn');
if (micToggleBtn) {
micToggleBtn.addEventListener('click', () => this.toggleMicVisibility());
}
}
updateNetworkStatus() {
const isOnline = navigator.onLine;
this.networkStatus.textContent = isOnline ? '📶 Online' : '❌ Offline';
this.networkStatus.style.color = isOnline ? 'var(--text-color)' : 'var(--error-color)';
}
updatePrompt() {
const path = '/' + this.fs.currentPath.join('/');
const label = document.querySelector('.prompt-label');
if (!label) return;
if (localStorage.getItem('webx11_smart_prompt') === 'off') {
label.textContent = path === '/' ? '$' : path + ' $';
return;
}
const indicators = [];
try {
const p = localStorage.getItem('webx11_user_profile');
if (p) { const pf = JSON.parse(p); if (pf.knownFacts && pf.knownFacts.name) indicators.push('👤' + pf.knownFacts.name); }
} catch (e) {}
try {
const idx = this.fs.fsReadJSON('/skills/index.json');
if (idx) { const c = Object.keys(idx).length; if (c > 0) indicators.push('📚' + c); }
} catch (e) {}
try {
const memIdx = this.fs.fsReadJSON('/memory/index.json');
if (memIdx) { const c = Object.keys(memIdx).length; if (c > 0) indicators.push('🧠' + c); }
} catch (e) {}
try {
if (performance.memory && performance.memory.usedJSHeapSize > 80 * 1048576) {
indicators.push('💾' + (performance.memory.usedJSHeapSize / 1048576).toFixed(0) + 'MB');
}
} catch (e) {}
try {
const md = this.fs.readDir('_mods');
if (md && md.content) { const e = Object.keys(md.content).filter(k => k.endsWith('.meta.json')); if (e.length > 0) indicators.push('⚡' + e.length); }
} catch (e) {}
const prefix = indicators.length ? indicators.join(' ') + ' ' : '';
label.textContent = prefix + (path === '/' ? '$' : path + ' $');
}
print(text, type = '') {
const line = document.createElement('div');
// Use card layout for typed messages (error/warning/info/success/text-muted), plain for bare text
const isTyped = ['error','warning','success'].includes(type);
if (isTyped) {
line.className = 'output-line msg-card system ' + type;
const safeText = String(text).replace(//g, ">");
line.innerHTML = '
' + safeText + '
';
} else {
line.className = 'output-line';
line.textContent = text;
}
this.outputContainer.appendChild(line);
this._historyBuffer.push({ l: text, t: type });
if (this._historyBuffer.length > this._historyMaxLines) {
this._historyBuffer.shift();
}
this._saveHistory();
this.scrollToBottom();
this._trimOutput();
}
_renderHTMLPreview(content) {
// AI returns raw HTML. We must HTML-escape for the srcdoc attribute
// so the parser treats it as attribute content, not tags.
// srcdoc decodes entities back to actual HTML inside the iframe.
const hasOwnStyle = /${content}`;
const safeSrcdoc = themedContent
.replace(/&/g, '&')
.replace(//g, '>')
.replace(/"/g, '"')
.replace(/`/g, '`');
return `📄 Rendered HTML ⛶ Fullscreen
`;
}
_renderHTMLBlock(text) {
const renderRegex = /:::render-html\r?\n([\s\S]*?)\r?\n:::/g;
let match;
let lastIndex = 0;
const fragments = [];
while ((match = renderRegex.exec(text)) !== null) {
// Text before this block
const before = text.slice(lastIndex, match.index);
if (before.trim()) {
const safeBefore = before.replace(//g, ">");
fragments.push({ type: 'text', html: '' + safeBefore + '
' });
}
fragments.push({ type: 'render', html: this._renderHTMLPreview(match[1].trim()) });
lastIndex = match.index + match[0].length;
}
const after = text.slice(lastIndex);
if (after.trim()) {
const safeAfter = after.replace(//g, ">");
fragments.push({ type: 'text', html: '' + safeAfter + '
' });
}
return fragments.length > 0 ? fragments : null;
}
printAIResponse(text) {
this._flushStreamChunks();
const fragments = this._renderHTMLBlock(text);
if (fragments) {
const wrapper = document.createElement('div');
wrapper.className = 'output-line msg-card ai';
wrapper.innerHTML = '
';
const body = wrapper.querySelector('.msg-card-body');
for (const frag of fragments) {
const div = document.createElement('div');
div.innerHTML = frag.html;
body.appendChild(div);
}
this.outputContainer.appendChild(wrapper);
this.scrollToBottom();
this._trimOutput();
return;
}
const wrapper = document.createElement('div');
wrapper.className = 'output-line msg-card ai';
const safeText = text.replace(//g, ">");
wrapper.innerHTML = '' + safeText + '
';
this.outputContainer.appendChild(wrapper);
this.scrollToBottom();
this._trimOutput();
}
printStreamingResponse(text, append) {
if (!this._streamChunks) this._streamChunks = [];
if (!append) {
const safeText = text.replace(//g, ">");
const wrapper = document.createElement('div');
wrapper.className = 'output-line msg-card ai';
wrapper.innerHTML = '' + safeText + '
';
this.outputContainer.appendChild(wrapper);
this._streamChunks = [];
} else {
const contentDiv = this.outputContainer.querySelector('.msg-card.ai:last-child .msg-card-body');
if (contentDiv) {
this._streamChunks.push(text);
if (this._streamChunks.length >= 5) {
contentDiv.textContent += this._streamChunks.join('');
this._streamChunks = [];
if (contentDiv.textContent.length > 50000) {
contentDiv.textContent = contentDiv.textContent.slice(-30000);
}
}
}
}
this.scrollToBottom();
this._trimOutput();
}
_flushStreamChunks() {
const contentDiv = this.outputContainer.querySelector('.msg-card.ai:last-child .msg-card-body');
if (contentDiv && this._streamChunks && this._streamChunks.length > 0) {
contentDiv.textContent += this._streamChunks.join('');
this._streamChunks = [];
}
}
printUserPrompt(rawInput) {
const wrapper = document.createElement('div');
wrapper.className = 'output-line msg-card user';
const safe = rawInput.replace(//g, '>');
const hasHtml = /<[a-z][\s\S]*>/i.test(rawInput.trim());
let buttons = 'Copy ';
if (hasHtml) buttons += 'Preview ';
buttons += 'Edit ';
wrapper.innerHTML = '' + safe + '
';
this.outputContainer.appendChild(wrapper);
this._historyBuffer.push({ l: rawInput, t: 'command-prompt' });
if (this._historyBuffer.length > this._historyMaxLines) this._historyBuffer.shift();
this.scrollToBottom();
this._trimOutput();
}
editPrompt(text) {
this.inputField.value = text;
this.inputField.focus();
this.inputField.setSelectionRange(text.length, text.length);
}
copyToClipboard(btn) {
let text = '';
const card = btn.closest('.msg-card');
if (card) {
const cardBody = card.querySelector('.msg-card-body');
if (cardBody) text = cardBody.textContent;
} else {
const nd = btn.nextElementSibling;
if (nd) text = nd.innerText || nd.textContent;
}
if (!text) return;
btn.textContent = 'Copied!';
setTimeout(function(){ btn.textContent = 'Copy'; }, 2000);
// Use navigator.clipboard on secure contexts (iOS 14+, modern browsers).
// This is the preferred API — NOTHING blocks it on HTTPS pages.
if (navigator.clipboard) {
navigator.clipboard.writeText(text).catch(function(){});
}
// Also try textarea+execCommand synchronously for older browsers.
// DO NOT wrap in setTimeout — iOS Safari requires execCommand to run
// in the same microtask as the user's click event.
try {
var ta = document.createElement('textarea');
ta.value = text;
ta.style.cssText = 'position:fixed;left:10px;top:10px;width:200px;height:40px;font-size:16px;padding:4px;z-index:-1;opacity:0.01';
document.body.appendChild(ta);
ta.focus();
ta.select();
ta.setSelectionRange(0, text.length);
document.execCommand('copy');
document.body.removeChild(ta);
} catch(e) {}
}
printClickable(items) {
const line = document.createElement('div');
line.className = 'output-line';
items.forEach(f => {
const span = document.createElement('span');
const safeName = f.name.replace(//g, '>');
if (f.type === 'dir') {
span.className = 'clickable-dir';
span.textContent = '[dir]' + f.name + '/';
span.onclick = () => {
this.executeCommand('cd ' + f.name);
setTimeout(() => this.executeCommand('ls'), 50);
};
} else {
span.className = 'clickable-file';
span.textContent = ' ' + f.name;
span.onclick = () => this.executeCommand('cat ' + f.name);
}
line.appendChild(span);
});
this.outputContainer.appendChild(line);
this.scrollToBottom();
}
printHTML(html) {
const line = document.createElement('div');
line.className = 'output-line';
line.innerHTML = html;
this.outputContainer.appendChild(line);
this.scrollToBottom();
}
showConfirmationBar(desc) {
const area = document.getElementById('ai-confirm-area');
if (!area) {
this.print('Confirmation UI unavailable — command blocked.', 'error');
return;
}
this._pendingCommandDesc = desc;
area.innerHTML = `
⚠️ ${desc}
✅ Allow
❌ Deny
Type: cmd-allow or cmd-deny
`;
area.classList.add('visible');
const allowBtn = document.getElementById('cmd-confirm-allow');
const denyBtn = document.getElementById('cmd-confirm-deny');
const onAllow = () => {
this._resolveCmdConfirm(true);
};
const onDeny = () => {
this._resolveCmdConfirm(false);
};
allowBtn.addEventListener('click', onAllow);
denyBtn.addEventListener('click', onDeny);
allowBtn.__allow = onAllow;
denyBtn.__deny = onDeny;
}
_resolveCmdConfirm(approved) {
const area = document.getElementById('ai-confirm-area');
if (area) {
area.classList.remove('visible');
area.innerHTML = '';
}
if (approved) {
this.print(`Allowed: ${this._pendingCommandDesc || 'command'}`, 'success');
if (this._pendingCommand) {
const fn = this._pendingCommand;
this._pendingCommand = null;
this._pendingCommandDesc = null;
fn();
}
} else {
this.print(`Denied: ${this._pendingCommandDesc || 'command'}`, 'warning');
this._pendingCommand = null;
this._pendingCommandDesc = null;
}
}
_loadHistory() {
try {
const saved = localStorage.getItem('webx11_terminal_output');
if (!saved) return;
const entries = JSON.parse(saved);
if (!Array.isArray(entries)) return;
this._historyBuffer = entries.slice(-this._historyMaxLines);
// Check localStorage quota warning
let quotaWarned = false;
try {
const testKey = '_quota_test_' + Date.now();
localStorage.setItem(testKey, 'x'.repeat(1024));
localStorage.removeItem(testKey);
} catch (e) {
quotaWarned = true;
}
// Lazy-load history: render first 20 immediately, batch the rest
const batchSize = 20;
const toRender = entries.slice(-this._historyMaxLines);
const initial = toRender.slice(0, batchSize);
for (const entry of initial) {
const line = document.createElement('div');
line.className = `output-line ${entry.t || ''}`;
line.textContent = entry.l || '';
this.outputContainer.appendChild(line);
}
if (toRender.length > batchSize) {
const remaining = toRender.slice(batchSize);
const MAX_BATCHES = 50;
const doBatch = (index, batchCount) => {
if (index >= remaining.length || batchCount >= MAX_BATCHES) {
this.scrollToBottom();
return;
}
requestAnimationFrame(() => {
const end = Math.min(index + batchSize, remaining.length);
for (let i = index; i < end; i++) {
const line = document.createElement('div');
line.className = `output-line ${remaining[i].t || ''}`;
line.textContent = remaining[i].l || '';
this.outputContainer.appendChild(line);
}
doBatch(end, (batchCount || 0) + 1);
});
};
doBatch(0, 0);
} else {
this.scrollToBottom();
}
if (quotaWarned) {
const warnLine = document.createElement('div');
warnLine.className = 'output-line warning';
warnLine.textContent = '⚠ localStorage is near quota — history may be incomplete';
this.outputContainer.appendChild(warnLine);
}
} catch (e) {
// Silently ignore corrupt history data
}
}
_saveHistory() {
if (this._historySavePending) return;
this._historySavePending = true;
setTimeout(() => {
this._historySavePending = false;
try {
const slice = this._historyBuffer.slice(-this._historyMaxLines);
localStorage.setItem('webx11_terminal_output', JSON.stringify(slice));
} catch (e) {
// Silently ignore storage errors (e.g. quota exceeded)
}
}, 3000);
}
_saveCommandHistory() {
try {
localStorage.setItem('webx11_command_history', JSON.stringify(this.history));
} catch (e) {
// Silently ignore storage errors
}
}
_trimOutput() {
const container = this.outputContainer;
while (container.children.length > this.MAX_OUTPUT_LINES) {
container.removeChild(container.firstChild);
}
}
markDirty() {
this._dirty = true;
}
_initAutoSave() {
if (this._autoSaveTimer) return;
this._autoSaveTimer = setInterval(() => {
// Auto-trim on memory pressure
try {
if (performance.memory && performance.memory.usedJSHeapSize > 80 * 1048576) {
const oldMax = this.MAX_OUTPUT_LINES;
this.MAX_OUTPUT_LINES = performance.memory.usedJSHeapSize > 120 * 1048576 ? 50 : 100;
this._trimOutput();
this.MAX_OUTPUT_LINES = oldMax;
this.print('🧹 Terminal trimmed (memory: ' + (performance.memory.usedJSHeapSize / 1048576).toFixed(0) + ' MB)', 'text-muted');
}
} catch (e) {}
if (!this._dirty) return;
this._autoSaveSession();
this._dirty = false;
}, 180000);
window.addEventListener('beforeunload', () => {
// Flush session before tab closes — avoids losing up to 3min of output on crash/close
try { this._autoSaveSession(); } catch (e) {}
if (this._autoSaveTimer) {
clearInterval(this._autoSaveTimer);
this._autoSaveTimer = null;
}
});
}
_autoSaveSession() {
try {
this.fs.mkdirRecursive('_sessions');
var rootClone;
try {
const json = JSON.stringify(this.fs.root);
if (json.length > 52428800) { // 50MB cap
rootClone = { type: 'directory', name: '/', content: {}, _truncated: true };
this.print('⚠ Auto-save skipped (VFS too large)', 'warning');
return;
}
rootClone = JSON.parse(json);
} catch (e) {
rootClone = { type: 'directory', name: '/', content: {}, _error: true };
return;
}
if (rootClone && rootClone.content && rootClone.content['_sessions']) {
delete rootClone.content['_sessions'];
}
const snapshot = {
type: 'session',
savedAt: new Date().toISOString(),
fsRoot: rootClone,
currentPath: this.fs.currentPath,
version: 1,
terminalLines: this._historyBuffer.slice(-50)
};
const snapshotJson = JSON.stringify(snapshot);
if (snapshotJson.length > 52428800) {
this.print('⚠ Auto-save snapshot too large, skipping', 'warning');
return;
}
this.fs.writeFile('_sessions/_auto.json', snapshotJson);
try {
const ctx = localStorage.getItem('webx11_ai_context');
if (ctx) this.fs.writeFile('_sessions/_auto-context.json', ctx);
} catch (e) {}
} catch (e) {
// Silently handle auto-save failures
}
}
_autoRestoreSession() {
try {
this.fs.mkdirRecursive('_sessions');
const autoSession = this.fs.readFile('_sessions/_auto.json');
if (autoSession) {
const data = JSON.parse(autoSession);
if (data && data.fsRoot) {
this.fs.root = data.fsRoot;
this.fs.currentPath = data.currentPath || [];
this.fs.saveToStorage();
this.print('↻ Auto-restored session from ' + (data.savedAt ? new Date(data.savedAt).toLocaleTimeString() : 'recent'), 'success');
// Defer AI context restoration (AI module loads after terminal init)
try {
const aiCtx = this.fs.readFile('_sessions/_auto-context.json');
if (aiCtx) {
localStorage.setItem('webx11_ai_context', aiCtx);
}
} catch (e) {}
}
}
} catch (e) {
// Silent on first boot — no auto-session yet
}
}
scrollToBottom() {
this.outputContainer.scrollTop = this.outputContainer.scrollHeight;
}
_getPathSuggestions(partial, type) {
// type: 'dir' = dirs only, 'file' = files + dirs (dirs get trailing /), 'session' = _sessions/*.json
if (type === 'session') {
this.fs.mkdirRecursive('_sessions');
const all = this.fs.ls('_sessions');
const names = all
.filter(e => e.type === 'file' && e.name.endsWith('.json'))
.map(e => e.name.replace('.json', ''));
return partial
? names.filter(n => n.startsWith(partial)).map(n => n)
: names;
}
// Resolve the partial path to determine which directory to list
let dirPath = '';
let filterPrefix = '';
let entries = [];
let prefix = '';
if (partial.includes('/')) {
// "dir1/dir2" or "dir1/dir2/" or "dir1/"
const lastSlash = partial.lastIndexOf('/');
const beforeSlash = partial.slice(0, lastSlash); // dir1
filterPrefix = partial.slice(lastSlash + 1); // dir2 (or '' if ends with /)
// Resolve "dir1" relative to current path
const resolvedDir = this.fs._resolveDir(beforeSlash || '.');
if (!resolvedDir) return [];
entries = Object.keys(resolvedDir.content).map(name => ({
name,
type: resolvedDir.content[name].type,
size: resolvedDir.content[name].content ? resolvedDir.content[name].content.length : 0
}));
prefix = beforeSlash + '/';
} else {
// No slash — list current dir
entries = this.fs.ls();
filterPrefix = partial;
prefix = '';
}
if (type === 'dir') {
return entries
.filter(e => e.type === 'dir' && e.name.startsWith(filterPrefix))
.map(e => prefix + e.name + '/');
}
// 'file' — show files + dirs (dirs with trailing /)
return entries
.filter(e => e.name.startsWith(filterPrefix))
.map(e => e.type === 'dir' ? prefix + e.name + '/' : prefix + e.name);
}
showAutocomplete() {
if (!this.moduleManager || !this.security) return;
const input = this.inputField.value;
const trimmed = input.trim();
const commands = this.moduleManager.getAllCommands() || {};
// Merge built-in internals into autocomplete (they're handled via if-chain, not module commands)
for (const c of this.security.commandMeta.keys()) {
if (!(c in commands)) commands[c] = 'builtin';
}
this.suggestions = [];
this.selectedSuggestion = -1;
this._isCdAutocomplete = false;
if (trimmed.length === 0) {
this.hideAutocomplete();
return;
}
const parts = input.split(' ');
const cmdPart = parts[0].toLowerCase();
const lowerInput = input.toLowerCase();
// Expanded command lists for autocomplete
const fileArgCommands = ['cat', 'preview', 'rm', 'rmdir', 'edit', 'view', 'cp', 'mv', 'touch', 'put', 'run', 'chmod', 'gzip', 'gunzip', 'encrypt', 'decrypt', 'download', 'webdav-backup', 'webdav-restore', 'webdav-upload', 'webdav-download', 'webdav-list'];
const dirArgCommands = ['ls', 'cd', 'mkdir', 'rmdir'];
const sessionArgCommands = ['session-load', 'session-delete', 'session-export', 'session-save', 'session-list', 'session-import'];
const pathArgCommands = ['cat', 'preview', 'rm', 'edit', 'view', 'cp', 'mv', 'touch', 'put', 'run', 'chmod', 'gzip', 'gunzip', 'encrypt', 'decrypt', 'download', 'webdav-backup', 'webdav-restore', 'webdav-upload', 'webdav-download', 'webdav-list', 'ls', 'cd', 'mkdir', 'rmdir', 'cat', 'preview'];
// Collect commands that take arguments from ext module metadata
const extFileCmds = new Set();
const extDirCmds = new Set();
for (const modName in this.moduleManager.modules) {
const mod = this.moduleManager.modules[modName];
if (mod.commandsMeta) {
for (const [c, meta] of Object.entries(mod.commandsMeta)) {
if (meta.argType === 'file' || meta.argType === 'path') extFileCmds.add(c);
if (meta.argType === 'dir') extDirCmds.add(c);
}
}
}
// If we have more than one word, we're past the command name — autocomplete file/dir/session names
if (parts.length > 1 && parts[1].length > 0) {
const partial = parts.slice(1).join(' ');
const partialTrimmed = partial.trim();
if (partialTrimmed === '..') {
this.hideAutocomplete();
return;
}
if (sessionArgCommands.includes(cmdPart)) {
this.suggestions = this._getPathSuggestions(partialTrimmed, 'session');
} else if (dirArgCommands.includes(cmdPart) || extDirCmds.has(cmdPart)) {
this.suggestions = this._getPathSuggestions(partialTrimmed, 'dir');
this._isCdAutocomplete = true;
} else if (fileArgCommands.includes(cmdPart) || extFileCmds.has(cmdPart)) {
this.suggestions = this._getPathSuggestions(partialTrimmed, 'file');
} else {
this.hideAutocomplete();
return;
}
this._autocompletePrefix = cmdPart + ' ';
if (this.suggestions.length > 0) {
this.renderAutocomplete();
} else {
this.hideAutocomplete();
}
return;
}
// Just typed a space after command — show all matching entries
if (parts.length > 1) {
if (sessionArgCommands.includes(cmdPart)) {
this.suggestions = this._getPathSuggestions('', 'session');
} else if (dirArgCommands.includes(cmdPart) || extDirCmds.has(cmdPart)) {
this.suggestions = this._getPathSuggestions('', 'dir');
this._isCdAutocomplete = true;
} else if (fileArgCommands.includes(cmdPart) || extFileCmds.has(cmdPart)) {
this.suggestions = this._getPathSuggestions('', 'file');
} else {
this.hideAutocomplete();
return;
}
this._autocompletePrefix = cmdPart + ' ';
if (this.suggestions.length > 0) {
this.renderAutocomplete();
} else {
this.hideAutocomplete();
}
return;
}
// Directory autocomplete for bare 'cd ' (no partial yet)
if (lowerInput.startsWith('cd ')) {
this.suggestions = this._getPathSuggestions('', 'dir');
if (this.suggestions.length > 0) {
this._isCdAutocomplete = true;
this.renderAutocomplete();
} else {
this.hideAutocomplete();
}
return;
}
// Command name autocomplete (first word only) — also clears any stale prefix
this._autocompletePrefix = '';
for (const cmd in commands) {
if (cmd.startsWith(cmdPart) && cmd !== cmdPart) {
this.suggestions.push(cmd);
}
}
if (this.suggestions.length > 0) {
this.renderAutocomplete();
} else {
this.hideAutocomplete();
}
}
renderAutocomplete() {
this.dropdown.innerHTML = '';
this.suggestions.forEach((item, index) => {
const div = document.createElement('div');
div.className = `autocomplete-item ${index === this.selectedSuggestion ? 'selected' : ''}`;
div.innerHTML = this._isCdAutocomplete
? `${item} `
: `${item} `;
div.onclick = () => {
if (this._autocompletePrefix) {
this.inputField.value = this._autocompletePrefix + item;
} else if (this._isCdAutocomplete) {
this.inputField.value = 'cd ' + item;
} else {
this.inputField.value = item + ' ';
}
this.hideAutocomplete();
this.inputField.focus();
};
this.dropdown.appendChild(div);
});
this.dropdown.style.display = 'block';
}
updateAutocompleteDisplay() {
const items = this.dropdown.querySelectorAll('.autocomplete-item');
items.forEach((item, index) => {
item.classList.toggle('selected', index === this.selectedSuggestion);
});
if (this.selectedSuggestion >= 0 && this.suggestions[this.selectedSuggestion]) {
if (this._autocompletePrefix) {
this.inputField.value = this._autocompletePrefix + this.suggestions[this.selectedSuggestion];
} else if (this._isCdAutocomplete) {
this.inputField.value = 'cd ' + this.suggestions[this.selectedSuggestion];
} else {
this.inputField.value = this.suggestions[this.selectedSuggestion] + ' ';
}
}
}
hideAutocomplete() {
this.dropdown.style.display = 'none';
this.suggestions = [];
this.selectedSuggestion = -1;
this._isCdAutocomplete = false;
this._autocompletePrefix = '';
}
loadBuiltInModules() {
this._safeRegister('SystemModule', 'system');
this._safeRegister('FileSystemModule', 'filesystem');
this._safeRegister('ModuleManagerModule', 'module manager');
this._safeRegister('OllamaModule', 'ollama');
this._safeRegister('SearXNGModule', 'searxng search');
this._safeRegister('ConfigManagerModule', 'config');
this._safeRegister('WebX11AIAssistantModule', 'AI assistant');
this._safeRegister('WebFetcherModule', 'web fetcher');
this._safeRegister('WebXAIAliasModule', 'AI alias');
this._safeRegister('SecretVault', 'secret vault');
this.reloadPersistedModules();
this._safeRegister('ChatStudioModule', 'chat studio');
this._safeRegister('AgentRegistryModule', 'agent registry');
this._safeRegister('LeaseModule', 'lease system');
this._safeRegister('OrchestratorModule', 'orchestrator');
this._safeRegister('NeuralVFSModule', 'neural VFS');
this._safeRegister('SandboxModule', 'sandbox');
this._safeRegister('MemoryGraphModule', 'memory graph');
this._safeRegister('PredictorModule', 'predictor');
this._safeRegister('WorldModelModule', 'world model');
this._safeRegister('ServerMonitorModule', 'server monitor');
this._safeRegister('SimpleGUIModule', 'simple GUI builder');
}
_initRegisteredModules() {
for (const [name, mod] of Object.entries(this.moduleManager.modules)) {
if (typeof mod.init === 'function') {
try { mod.init(this); } catch (e) {
console.warn(`[Init] ${name} failed: ${e.message}`);
}
}
}
}
_safeRegister(className, label) {
let cls;
try {
cls = eval(className);
} catch (e) {
console.log(`[SafeRegister] Skipped ${label}: class not loaded (skipped in boot selection)`);
return;
}
if (typeof cls === 'undefined') {
console.log(`[SafeRegister] Skipped ${label}: class not defined`);
return;
}
try { this.moduleManager.registerModule(new cls()); }
catch (e) { console.warn(`[SafeRegister] Skipped ${label}: ${e.message}`); }
}
// ── Config Registry ──
registerConfigItem(key, defaultValue, setterFn) {
this._configRegistry[key] = { key, defaultValue };
this._configDefaults[key] = defaultValue;
this._configSetters[key] = setterFn;
// Apply persisted override if present
const saved = localStorage.getItem('webx11_terminal_config');
if (saved) {
try {
const parsed = JSON.parse(saved);
if (parsed[key] !== undefined && parsed[key] !== defaultValue) {
setterFn(parsed[key]);
}
} catch (e) {}
}
}
_loadTerminalConfig() {
try {
const saved = localStorage.getItem('webx11_terminal_config');
if (!saved) return;
const parsed = JSON.parse(saved);
// Apply registered config items from persistence
for (const [key, val] of Object.entries(parsed)) {
if (this._configSetters[key] && val !== this._configDefaults[key]) {
this._configSetters[key](val);
}
}
} catch (e) {}
}
_saveTerminalConfig() {
const values = {};
for (const key of Object.keys(this._configRegistry)) {
const actual = this[key] !== undefined ? this[key] : this._configDefaults[key];
if (actual !== this._configDefaults[key]) {
values[key] = actual;
}
}
localStorage.setItem('webx11_terminal_config', JSON.stringify(values));
}
_applyTerminalConfig() {
try {
const saved = localStorage.getItem('webx11_terminal_config');
if (!saved) return;
const parsed = JSON.parse(saved);
if (parsed['terminal-max-lines'] !== undefined) this.MAX_OUTPUT_LINES = parsed['terminal-max-lines'];
if (parsed['terminal-hist-output'] !== undefined) this._historyMaxLines = parsed['terminal-hist-output'];
if (parsed['terminal-cmd-history'] !== undefined) this._historyCap = parsed['terminal-cmd-history'];
if (parsed['terminal-pipe-buffer'] !== undefined) this._pipeBufferSize = parsed['terminal-pipe-buffer'];
} catch (e) {}
}
async _loadExtModules(filterKeys) {
const autoLoadKeys = filterKeys || ['calc', 'voice', 'cron', 'theme', 'wpm', 'mindmap', 'webdav', 'skill-pack-core', 'editor', 'diff', 'alias', 'finder', 'filemgr', 'cryptogzip', 'shell', 'tutorial', 'busybox'];
const cacheVer = localStorage.getItem('webx11_cache_ver') || '2026-05-27-14';
try {
const res = await fetch('packages.json?v=' + cacheVer);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const index = await res.json();
for (const key of autoLoadKeys) {
const pkg = index.packages[key];
if (pkg) {
const url = pkg.url + (pkg.url.includes('?') ? '&v=' : '?v=') + cacheVer;
await this.moduleManager.loadModuleFromURL(url);
}
}
} catch (err) {
this.print(`Failed to auto-load ext modules: ${err.message}`, 'error');
}
}
async reloadPersistedModules() {
const registry = this.moduleManager.moduleRegistry;
const skipList = ['calc', 'voice', 'cron', 'theme', 'wpm', 'mindmap', 'webdav', 'skill-pack-core', 'editor', 'diff', 'alias', 'finder', 'filemgr', 'cryptogzip', 'shell', 'tutorial', 'busybox'];
for (const mod of registry) {
if (mod.enabled && mod.url) {
// Skip ext modules — they load via _loadExtModules
const name = (mod.url.match(/(\w+)\.js/) || [])[1];
if (name && skipList.includes(name)) continue;
await this.moduleManager.loadModuleFromURL(mod.url);
}
}
}
async executeCommand(rawInput) {
if (!rawInput.trim()) return;
this.markDirty();
// Push to command history (dedup + trim)
const cmd = rawInput.trim();
if (this.history.length === 0 || this.history[this.history.length - 1] !== cmd) {
this.history.push(cmd);
}
if (this.history.length > this._historyCap) {
this.history = this.history.slice(-this._historyCap);
}
this.historyIndex = this.history.length;
this._saveCommandHistory();
this.printUserPrompt(rawInput);
const pipeParts = this.splitPipe(rawInput.trim());
if (pipeParts.length > 1) {
await this.executePipeChain(pipeParts);
return;
}
// Security gate for all single commands and internal builtins
await this.security.checkAndRun(rawInput);
}
splitPipe(input) {
const parts = [];
let current = '';
let inQuote = false;
for (const ch of input) {
if (ch === '"' || ch === "'") { inQuote = !inQuote; current += ch; }
else if (ch === '|' && !inQuote) { parts.push(current.trim()); current = ''; }
else { current += ch; }
}
parts.push(current.trim());
return parts;
}
async executePipeChain(pipeParts) {
let pipeInput = null;
for (let i = 0; i < pipeParts.length; i++) {
const cmdText = pipeParts[i];
const parts = this.security._parseArgs(cmdText);
const cmd = parts[0].toLowerCase();
const args = parts.slice(1);
this._pipeInput = pipeInput;
if (i < pipeParts.length - 1) {
pipeInput = await this.capturePipeOutput(cmd, args);
} else {
await this.dispatchCommand(cmd, args);
}
}
this._pipeInput = null;
}
async capturePipeOutput(cmd, args) {
const originalPrint = this.print.bind(this);
let output = '';
this.print = (text, type) => { output += text + '\n'; };
await this.dispatchCommand(cmd, args);
this.print = originalPrint;
const trimmed = output.trim();
if (trimmed.length > this._pipeBufferSize) {
return trimmed.slice(0, this._pipeBufferSize);
}
return trimmed;
}
async dispatchCommand(cmd, args) {
if (cmd === 'clear') { this.outputContainer.innerHTML = ''; this._historyBuffer = []; localStorage.removeItem('webx11_terminal_output'); return; }
if (cmd === 'startup-modules') { this.openStartupModules(); return; }
if (cmd === 'font-size' || cmd === 'setfont') { this.setFontSize(args[0]); return; }
if (cmd === 'load-local' || cmd === 'loadlocal') { this.loadLocalModule(args[0]); return; }
if (cmd === 'smart-prompt') { const val = args[0] && args[0] === 'off' ? 'off' : 'on'; localStorage.setItem('webx11_smart_prompt', val); this.print('Smart prompt ' + val, 'success'); this.updatePrompt(); return; }
if (cmd === 'mem') { this.printMemoryStats(); return; }
if (cmd === 'config') {
if (args.length === 0) {
this.print('=== Terminal Config ===', 'info');
this.print(`terminal-max-lines: ${this.MAX_OUTPUT_LINES} (default: 200)`, '');
this.print(`terminal-hist-output: ${this._historyMaxLines} (default: 50)`, '');
this.print(`terminal-cmd-history: ${this._historyCap} (default: 200)`, '');
this.print(`terminal-pipe-buffer: ${this._pipeBufferSize} (default: 5000)`, '');
this.print('Usage: config ', 'info');
this.print('Keys: terminal-max-lines, terminal-hist-output, terminal-cmd-history, terminal-pipe-buffer', 'info');
return;
}
const key = args[0];
const val = args[1];
if (val === undefined) { this.print('Usage: config ', 'warning'); return; }
const n = parseInt(val);
if (isNaN(n) || n < 1) { this.print('Value must be a positive number', 'warning'); return; }
if (key === 'terminal-max-lines') { this.MAX_OUTPUT_LINES = n; }
else if (key === 'terminal-hist-output') { this._historyMaxLines = n; }
else if (key === 'terminal-cmd-history') { this._historyCap = n; }
else if (key === 'terminal-pipe-buffer') { this._pipeBufferSize = n; }
else { this.print(`Unknown key: ${key}. Valid: terminal-max-lines, terminal-hist-output, terminal-cmd-history, terminal-pipe-buffer`, 'warning'); return; }
this._saveTerminalConfig();
this.print(`config ${key} set to ${val}`, 'success');
return;
}
if (cmd === 'security') {
this.print(`Security level: ${this.security.getLevel()} (${this.security.getLevelName()})`, 'info');
this.print(`Blocked commands: ${this.security.blockedCount}`, 'info');
this.print(`Destructive set: ${[...this.security.destructiveCmds].sort().join(', ')}`, '');
this.print(`System set: ${[...this.security.systemCmds].sort().join(', ')}`, '');
this.print(`Suspicious flags: ${[...this.security.suspiciousFlags].sort().join(', ')}`, '');
this.print(`Whitelist: ${[...this.security.whitelist].sort().join(', ')}`, '');
const all = this.security._collectAllCommands();
this.print(`Registered commands: ${all.length}`, '');
return;
}
if (cmd === 'security-level') {
const lv = parseInt(args[0]);
if (isNaN(lv)) { this.print('Usage: security-level 0|1|2|3', 'warning'); return; }
this.security.setLevel(lv);
localStorage.setItem('webx11_security_level', lv);
return;
}
if (cmd === 'security-destructive') {
const action = args[0];
if (!action) { this.print('Usage: security-destructive add|remove|list [cmd]', 'warning'); return; }
if (action === 'list') {
this.print('Destructive commands:', 'info');
for (const c of this.security.destructiveCmds) this.print(` ${c}`, '');
return;
}
if (!args[1]) { this.print('Usage: security-destructive add|remove ', 'warning'); return; }
if (action === 'add') { this.security.destructiveCmds.add(args[1]); }
else if (action === 'remove') { this.security.destructiveCmds.delete(args[1]); }
else { this.print('Action must be: add, remove, or list', 'warning'); return; }
localStorage.setItem('webx11_security_destructive', JSON.stringify([...this.security.destructiveCmds]));
this.print(`Updated destructive set (${this.security.destructiveCmds.size} commands)`, 'success');
return;
}
if (cmd === 'security-system') {
const action = args[0];
if (!action) { this.print('Usage: security-system add|remove|list [cmd]', 'warning'); return; }
if (action === 'list') {
this.print('System commands:', 'info');
for (const c of this.security.systemCmds) this.print(` ${c}`, '');
return;
}
if (!args[1]) { this.print('Usage: security-system add|remove ', 'warning'); return; }
if (action === 'add') { this.security.systemCmds.add(args[1]); }
else if (action === 'remove') { this.security.systemCmds.delete(args[1]); }
else { this.print('Action must be: add, remove, or list', 'warning'); return; }
localStorage.setItem('webx11_security_system', JSON.stringify([...this.security.systemCmds]));
this.print(`Updated system set (${this.security.systemCmds.size} commands)`, 'success');
return;
}
if (cmd === 'security-whitelist') {
const action = args[0];
if (!action) { this.print('Usage: security-whitelist add|remove|list [cmd]', 'warning'); return; }
if (action === 'list') {
this.print('Whitelist commands:', 'info');
for (const c of this.security.whitelist) this.print(` ${c}`, '');
return;
}
if (!args[1]) { this.print('Usage: security-whitelist add|remove ', 'warning'); return; }
if (action === 'add') { this.security.whitelist.add(args[1]); }
else if (action === 'remove') { this.security.whitelist.delete(args[1]); }
else { this.print('Action must be: add, remove, or list', 'warning'); return; }
localStorage.setItem('webx11_security_whitelist', JSON.stringify([...this.security.whitelist]));
this.print(`Updated whitelist (${this.security.whitelist.size} commands)`, 'success');
return;
}
if (cmd === 'security-flag') {
const action = args[0];
if (!action) { this.print('Usage: security-flag add|remove|list [flag]', 'warning'); return; }
if (action === 'list') {
this.print('Suspicious flags:', 'info');
for (const f of this.security.suspiciousFlags) this.print(` ${f}`, '');
return;
}
if (!args[1]) { this.print('Usage: security-flag add|remove ', 'warning'); return; }
const flag = args[1].startsWith('--') ? args[1] : '--' + args[1];
if (action === 'add') { this.security.suspiciousFlags.add(flag); }
else if (action === 'remove') { this.security.suspiciousFlags.delete(flag); }
else { this.print('Action must be: add, remove, or list', 'warning'); return; }
localStorage.setItem('webx11_security_flags', JSON.stringify([...this.security.suspiciousFlags]));
this.print(`Updated suspicious flags (${this.security.suspiciousFlags.size} flags)`, 'success');
return;
}
if (cmd === 'cmd-allow') { this._resolveCmdConfirm(true); return; }
if (cmd === 'cmd-deny') { this._resolveCmdConfirm(false); return; }
if (cmd === 'vfs' && args[0] === 'reset') {
if (args[1] !== '--force') {
this.print('⚠ This will permanently delete all files! Use vfs reset --force to confirm.', 'warning');
return;
}
this.fs.reset();
this.print('VFS reset — all files deleted.', 'success');
return;
}
if (cmd === 'vfs' && args[0] === 'clean') {
let count = 0;
const sysDirs = ['_snapshots', '_sessions', '.trash'];
for (const dir of sysDirs) {
if (this.fs.root.content && this.fs.root.content[dir]) {
delete this.fs.root.content[dir];
count++;
}
}
if (count > 0) {
this.fs.saveToStorage();
this.print('🧹 Cleaned ' + count + ' system dirs: ' + sysDirs.join(', '), 'success');
} else {
this.print('Nothing to clean — system dirs already empty.', 'info');
}
return;
}
if (cmd === 'vfs-clean') {
let count = 0;
const sysDirs = ['_snapshots', '_sessions', '.trash'];
for (const dir of sysDirs) {
if (this.fs.root.content && this.fs.root.content[dir]) {
delete this.fs.root.content[dir];
count++;
}
}
if (count > 0) {
this.fs.saveToStorage();
this.print('🧹 Cleaned ' + count + ' system dirs: ' + sysDirs.join(', '), 'success');
} else {
this.print('Nothing to clean — system dirs already empty.', 'info');
}
return;
}
if (cmd === 'empty-trash') {
if (args[0] !== '--force') {
this.print('⚠ This will permanently delete all trashed files! Use empty-trash --force to confirm.', 'warning');
return;
}
if (this.fs.emptyTrash()) {
this.print('Trash emptied.', 'success');
} else {
this.print('Trash is already empty.', 'info');
}
return;
}
if (cmd === 'history') {
const search = args.join(' ').toLowerCase();
const entries = this.history;
if (!entries.length) { this.print('No command history.', 'info'); return; }
if (search) {
const filtered = entries.map((c, i) => ({ i, c })).filter(e => e.c.toLowerCase().includes(search));
if (!filtered.length) { this.print(`No history entries matching: ${search}`, 'info'); return; }
this.print(`=== History (matching: ${search}) ===`, 'info');
filtered.forEach(e => this.print(` ${e.i + 1} ${e.c}`));
} else {
this.print(`=== Command History (${entries.length} entries) ===`, 'info');
const start = Math.max(0, entries.length - 50);
for (let i = start; i < entries.length; i++) {
this.print(` ${i + 1} ${entries[i]}`);
}
if (entries.length > 50) this.print(`... (${entries.length - 50} older entries hidden, use history to find)`);
}
return;
}
for (const modName in this.moduleManager.modules) {
const mod = this.moduleManager.modules[modName];
if (mod.commands && mod.commands[cmd]) {
try { await mod.commands[cmd](args);
// Notify tutorial module for quest tracking
if (this.tutorialModule && cmd !== 'tutorial') this.tutorialModule.onCommandExecuted(cmd, args);
// Memory graph: record file access for read/file commands
if (cmd === 'cat' || cmd === 'view_module' || cmd === 'edit_module') {
try {
const _mg = this.moduleManager.modules['MemoryGraphModule'];
if (_mg && args[0]) _mg.recordFileAccess(args[0]);
} catch (_e) {}
}
return; }
catch (err) { this.print(`Error in ${modName}.${cmd}: ${err.message}`, 'error'); return; }
}
}
// Enhanced: check fuzzy suggestions before showing "not found"
const suggestion = this.security.suggest(cmd);
if (suggestion && suggestion.dist <= 2 && suggestion.dist > 0) {
this.print(`Command not found: ${cmd}`, 'error');
this.print(`Did you mean: ${suggestion.cmd}? (yes) (no)`, 'info');
// Render clickable yes/no
const out = this.outputContainer;
if (out) {
const yesSpan = document.createElement('span');
yesSpan.textContent = ' [yes] ';
yesSpan.style.cssText = 'color:var(--success-color);cursor:pointer;text-decoration:underline;margin-right:12px';
yesSpan.onclick = () => this.executeCommand(suggestion.cmd + (args.length ? ' ' + args.join(' ') : ''));
const noSpan = document.createElement('span');
noSpan.textContent = ' [no] ';
noSpan.style.cssText = 'color:var(--error-color);cursor:pointer;text-decoration:underline';
noSpan.onclick = () => { this.print('Dismissed.', 'text-muted'); };
const line = out.lastElementChild;
if (line) { line.appendChild(yesSpan); line.appendChild(noSpan); }
}
return;
}
this.print(`Command not found: ${cmd}`, 'error');
}
openFileBrowser() {
const modal = document.getElementById('file-browser');
const content = document.getElementById('fb-content');
content.innerHTML = '';
const files = this.fs.ls();
if (files.length === 0) {
content.innerHTML = 'Directory Empty
';
} else {
files.forEach(f => {
const div = document.createElement('div');
div.className = `file-item ${f.type === 'dir' ? 'dir' : ''}`;
div.innerHTML = `${f.type === 'dir' ? '📁' : '📄'} ${f.name} ${f.size}b `;
div.onclick = () => {
if (f.type === 'file') {
const content = this.fs.readFile(f.name);
this.print(`--- ${f.name} ---`);
this.print(content);
this.print('--- End ---');
this.closeFileBrowser();
}
};
content.appendChild(div);
});
}
modal.style.display = 'flex';
}
closeFileBrowser() {
document.getElementById('file-browser').style.display = 'none';
this.inputField.focus();
}
openModuleManager() {
const modal = document.getElementById('module-manager');
this.renderModuleList();
modal.style.display = 'flex';
}
closeModuleManager() {
document.getElementById('module-manager').style.display = 'none';
this.inputField.focus();
}
renderModuleList() {
const list = document.getElementById('module-list');
list.innerHTML = '';
const modules = this.moduleManager.getModuleList();
if (modules.length === 0) {
list.innerHTML = 'No modules loaded
';
return;
}
modules.forEach(mod => {
const div = document.createElement('div');
div.className = `module-item ${mod.loaded ? '' : 'disabled'}`;
const badge = mod.url ? '' : 'Built-in ';
div.innerHTML = `
${mod.name} ${badge}
${mod.url || 'Built-in'}
${mod.url ? `✕ ` : ''}
`;
list.appendChild(div);
});
}
async loadModuleFromURL() {
const url = document.getElementById('module-url-input').value.trim();
if (!url) { alert('Please enter a URL'); return; }
const success = await this.moduleManager.loadModuleFromURL(url);
if (success) {
document.getElementById('module-url-input').value = '';
this.renderModuleList();
}
}
removeModule(name) {
if (this.moduleManager.unregisterModule(name)) {
this.print(`Module '${name}' removed`, 'success');
this.renderModuleList();
}
}
clearAllModules() {
if (confirm('Remove all custom modules? Built-in modules will remain.')) {
this.moduleManager.clearAll();
this.renderModuleList();
this.print('All custom modules cleared', 'success');
}
}
openOllamaManager() {
const modal = document.getElementById('ollama-manager');
document.getElementById('ollama-endpoint').value = this.ollamaManager.endpoint;
this.updateOllamaStatus();
this.renderOllamaModels();
this.initOllamaUI();
modal.style.display = 'flex';
}
closeOllamaManager() {
document.getElementById('ollama-manager').style.display = 'none';
this.inputField.focus();
}
openPreview(args) {
let path = '';
if (typeof args === 'string') path = args.trim();
else if (Array.isArray(args)) path = args.join(' ').trim();
if (!path) {
this.print('Usage: preview ', 'warning');
return;
}
// Remove leading slash for VFS paths
const vfsPath = path.replace(/^\/*/, '');
let content = this.fs.readFile(vfsPath);
if (content === null) content = this.fs.readFile('/' + vfsPath);
if (content === null) {
this.print(`File not found: ${path}`, 'error');
return;
}
// Use srcdoc instead of blob URL — works reliably on mobile browsers
const iframe = document.getElementById('preview-iframe');
iframe.srcdoc = content;
document.getElementById('preview-title').textContent = `📄 ${path}`;
document.getElementById('preview-modal').style.display = 'flex';
}
closePreview() {
document.getElementById('preview-modal').style.display = 'none';
const iframe = document.getElementById('preview-iframe');
iframe.srcdoc = '';
iframe.src = 'about:blank';
this.inputField.focus();
}
openRenderPreview(btn) {
const content = btn.getAttribute('data-content');
if (!content) return;
const iframe = document.getElementById('preview-iframe');
// The srcdoc content is already HTML-escaped from _renderHTMLPreview
const decoded = content.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/`/g, '`');
iframe.srcdoc = decoded;
document.getElementById('preview-title').textContent = '📄 AI Rendered Preview (Fullscreen)';
document.getElementById('preview-modal').style.display = 'flex';
}
previewHtmlFromPrompt(btn) {
const content = btn.getAttribute('data-content');
if (!content) return;
const iframe = document.getElementById('preview-iframe');
// Content is HTML-escaped, decode it back
const decoded = content.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/`/g, '`');
// Wrap in a basic HTML template if it doesn't have html/head/body tags
const hasOwnDoc = / ${decoded}`;
iframe.srcdoc = fullDoc;
document.getElementById('preview-title').textContent = '📄 User HTML Preview';
document.getElementById('preview-modal').style.display = 'flex';
}
openSkillsManager() {
const modal = document.getElementById('skills-manager');
// Ensure /skills/ directory exists
this.fs.mkdirRecursive('/skills');
this.refreshSkillsList();
modal.style.display = 'flex';
}
closeSkillsManager() {
document.getElementById('skills-manager').style.display = 'none';
this.inputField.focus();
}
openConfigManager() {
document.getElementById('config-manager').style.display = 'flex';
this.renderConfigList();
}
closeConfigManager() {
document.getElementById('config-manager').style.display = 'none';
this.inputField.focus();
}
renderConfigList() {
const list = document.getElementById('config-list');
if (!list) return;
const prefix = 'webx11_config_';
const entries = [];
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key && key.startsWith(prefix)) {
entries.push({ key: key.replace(prefix, ''), value: localStorage.getItem(key) });
}
}
if (entries.length === 0) {
list.innerHTML = 'No configuration saved.
';
return;
}
list.innerHTML = entries.map(e => {
const displayVal = e.key.toLowerCase().includes('password') || e.key.toLowerCase().includes('token') || e.key.toLowerCase().includes('secret')
? '****' : e.value;
return ``;
}).join('');
}
addConfigFromUI() {
const key = document.getElementById('config-key-input').value.trim();
const value = document.getElementById('config-value-input').value.trim();
if (!key || !value) {
this.print('Enter both key and value', 'warning');
return;
}
localStorage.setItem('webx11_config_' + key, value);
document.getElementById('config-key-input').value = '';
document.getElementById('config-value-input').value = '';
this.print(`Config saved: ${key} = ${value}`, 'success');
this.renderConfigList();
}
deleteConfigFromUI(key) {
localStorage.removeItem('webx11_config_' + key);
this.print(`Config '${key}' deleted`, 'success');
this.renderConfigList();
}
exportConfigFromUI() {
const mod = this.moduleManager.modules['ConfigManagerModule'];
if (mod && mod.commands && mod.commands['config-export']) {
mod.commands['config-export']();
}
}
resetConfigFromUI() {
const mod = this.moduleManager.modules['ConfigManagerModule'];
if (mod && mod.commands && mod.commands['config-reset']) {
mod.commands['config-reset']();
this.renderConfigList();
}
}
refreshSkillsList() {
const list = document.getElementById('skills-list');
list.innerHTML = '';
const fs = this.fs;
fs.mkdirRecursive('/skills');
const index = fs.fsReadJSON('/skills/index.json') || {};
const names = Object.keys(index);
if (names.length === 0) {
list.innerHTML = 'No skills yet. Create one below!
';
return;
}
names.forEach(name => {
const s = index[name];
const tags = (s.tags || []).join(', ');
const div = document.createElement('div');
div.className = 'module-item';
div.innerHTML = `
${name}
${s.description || ''}
${s.category || 'general'}${tags ? ' · ' + tags : ''}
👁
✕
`;
list.appendChild(div);
});
}
viewSkillUI(name) {
const fs = this.fs;
const content = fs.readFile(`/skills/${name}.md`);
if (!content) { this.print(`Skill '${name}' not found`, 'error'); return; }
document.getElementById('skill-preview-title').textContent = name;
document.getElementById('skill-preview-content').textContent = content;
document.getElementById('skill-preview').style.display = 'flex';
}
closeSkillPreview() {
document.getElementById('skill-preview').style.display = 'none';
}
deleteSkillUI(name) {
if (!confirm(`Delete skill "${name}"?`)) return;
const fs = this.fs;
fs.mkdirRecursive('/skills');
// Actually remove from VFS tree
if (fs.root && fs.root.content && fs.root.content['skills'] && fs.root.content['skills'].content) {
delete fs.root.content['skills'].content[`${name}.md`];
}
const index = fs.fsReadJSON('/skills/index.json') || {};
delete index[name];
fs.fsWriteJSON('/skills/index.json', index);
this.print(`Skill '${name}' deleted`, 'success');
this.refreshSkillsList();
fs.saveToStorage();
}
createSkillFromUI() {
const name = document.getElementById('skill-name-input').value.trim().replace(/\s+/g, '-').toLowerCase();
const description = document.getElementById('skill-desc-input').value.trim() || 'No description';
const tags = document.getElementById('skill-tags-input').value.trim().split(',').map(t => t.trim()).filter(Boolean);
const contentInput = document.getElementById('skill-content-input').value.trim();
if (!name) { alert('Skill name is required'); return; }
const fs = this.fs;
fs.mkdirRecursive('/skills');
if (fs.readFile(`/skills/${name}.md`)) {
alert(`Skill '${name}' already exists. Delete it first or use skill-patch command.`);
return;
}
let content = contentInput || `---\nname: ${name}\ndescription: ${description}\ntags: [${tags.join(', ')}]\ncategory: general\n---\n## Steps\n1. (to be filled)\n`;
if (!content.startsWith('---')) {
content = `---\nname: ${name}\ndescription: ${description}\ntags: [${tags.join(', ')}]\ncategory: general\n---\n${content}`;
}
fs.writeFile(`/skills/${name}.md`, content);
const index = fs.fsReadJSON('/skills/index.json') || {};
index[name] = { name, description, category: 'general', tags };
fs.fsWriteJSON('/skills/index.json', index);
this.print(`Skill '${name}' created`, 'success');
document.getElementById('skill-name-input').value = '';
document.getElementById('skill-desc-input').value = '';
document.getElementById('skill-tags-input').value = '';
document.getElementById('skill-content-input').value = '';
this.refreshSkillsList();
}
updateOllamaStatus() {
const statusDiv = document.getElementById('ollama-status');
const statusText = document.getElementById('ollama-status-text');
if (this.ollamaManager.connected) {
statusDiv.className = 'ollama-status connected';
statusText.textContent = `Connected (${this.ollamaManager.models.length} models)`;
} else {
statusDiv.className = 'ollama-status disconnected';
statusText.textContent = 'Disconnected';
}
}
renderOllamaModels() {
const container = document.getElementById('ollama-models');
const select = document.getElementById('ollama-current-model');
const searchInput = document.getElementById('ollama-search');
const countDiv = document.getElementById('ollama-model-count');
const query = (searchInput ? searchInput.value : '').toLowerCase().trim();
container.innerHTML = '';
select.innerHTML = 'Select a model ';
const models = this.ollamaManager.getModels();
if (models.length === 0) {
container.innerHTML = 'Connect to see models
';
if (countDiv) countDiv.textContent = '';
return;
}
const filtered = query ? models.filter(m => m.name.toLowerCase().includes(query)) : models;
if (countDiv) countDiv.textContent = filtered.length === models.length
? models.length + ' model' + (models.length !== 1 ? 's' : '')
: filtered.length + ' / ' + models.length + ' model' + (models.length !== 1 ? 's' : '');
if (filtered.length === 0) {
container.innerHTML = 'No models match "' + query + '"
';
return;
}
filtered.forEach(model => {
const div = document.createElement('div');
div.className = 'model-item';
div.innerHTML = `${model.name} ${model.size ? (model.size / 1e9).toFixed(1) + 'GB' : ''} `;
div.onclick = () => {
this.ollamaManager.setModel(model.name);
select.value = model.name;
};
container.appendChild(div);
const option = document.createElement('option');
option.value = model.name;
option.textContent = model.name;
if (model.name === this.ollamaManager.currentModel) { option.selected = true; }
select.appendChild(option);
});
}
async connectOllama() {
const endpoint = document.getElementById('ollama-endpoint').value.trim();
this.ollamaManager.setEndpoint(endpoint);
const success = await this.ollamaManager.connect();
if (success) { this.renderOllamaModels(); this._populateValidatorModels(); this.updateOllamaStatus(); }
}
setOllamaModel() {
const select = document.getElementById('ollama-current-model');
this.ollamaManager.setModel(select.value);
}
toggleOptimization() {
const cb = document.getElementById('ollama-optimization-toggle');
const label = document.getElementById('ollama-optimization-label');
const slider = document.getElementById('ollama-optimization-slider');
const enabled = cb.checked;
this.ollamaManager.setOptimizationEnabled(enabled);
if (label) {
label.textContent = enabled ? 'Enabled' : 'Disabled';
label.style.color = enabled ? 'var(--success-color)' : 'var(--text-muted)';
}
if (slider) {
slider.style.background = enabled ? 'var(--accent-color)' : 'var(--bg-tertiary)';
slider.style.borderColor = enabled ? 'var(--success-color)' : 'var(--border-color)';
}
this.ollamaManager.saveSettings();
}
toggleValidator() {
const cb = document.getElementById('ollama-validator-toggle');
const label = document.getElementById('ollama-validator-label');
const slider = document.getElementById('ollama-validator-slider');
const enabled = cb.checked;
this.ollamaManager.validatorEnabled = enabled;
if (label) {
label.textContent = enabled ? 'Enabled' : 'Disabled';
label.style.color = enabled ? 'var(--success-color)' : 'var(--text-muted)';
}
if (slider) {
slider.style.background = enabled ? 'var(--info-color)' : 'var(--bg-tertiary)';
slider.style.borderColor = enabled ? 'var(--info-color)' : 'var(--border-color)';
}
this.ollamaManager.saveSettings();
}
toggleStream() {
const cb = document.getElementById('ollama-stream-toggle');
const label = document.getElementById('ollama-stream-label');
const slider = document.getElementById('ollama-stream-slider');
const enabled = cb.checked;
this.ollamaManager.setStreamEnabled(enabled);
if (label) {
label.textContent = enabled ? 'Enabled' : 'Disabled';
label.style.color = enabled ? 'var(--success-color)' : 'var(--text-muted)';
}
if (slider) {
slider.style.background = enabled ? 'var(--accent-color)' : 'var(--bg-tertiary)';
slider.style.borderColor = enabled ? 'var(--success-color)' : 'var(--border-color)';
}
}
updateStreamIndicator() {
const el = document.getElementById('stream-status');
if (!el) return;
const enabled = this.ollamaManager?.streamEnabled !== false;
el.textContent = enabled ? '▶ Stream' : '🔇 No-Stream';
el.style.color = enabled ? 'var(--success-color)' : 'var(--text-muted)';
}
saveValidatorConfig() {
const select = document.getElementById('validator-model-select');
const endpoint = document.getElementById('validator-endpoint-input')?.value?.trim() || 'http://localhost:11434';
const model = select ? select.value : '';
localStorage.setItem('webx11_validator_endpoint', endpoint);
this.ollamaManager.validatorEndpoint = endpoint;
this.ollamaManager.setValidatorConfig(this.ollamaManager.validatorEnabled, model);
this.print('Validator config saved. Model: ' + (model || '(none)') + ' at ' + endpoint, 'success');
}
async connectValidatorEndpoint() {
const endpoint = document.getElementById('validator-endpoint-input')?.value?.trim() || 'http://localhost:11434';
const statusEl = document.getElementById('validator-connection-status');
const select = document.getElementById('validator-model-select');
if (statusEl) statusEl.textContent = '⏳ Connecting to ' + endpoint + '...';
if (select) { select.disabled = true; select.innerHTML = 'Connecting... '; }
try {
const resp = await fetch(endpoint + '/api/tags');
if (!resp.ok) throw new Error('HTTP ' + resp.status);
const data = await resp.json();
const models = data.models || [];
if (statusEl) statusEl.textContent = '✅ Connected: ' + models.length + ' model(s)';
// Populate dropdown
if (select) {
select.innerHTML = 'Select a validator model ';
models.forEach(function(m) {
var opt = document.createElement('option');
opt.value = m.name;
opt.textContent = m.name;
select.appendChild(opt);
});
select.disabled = false;
}
// Save endpoint
localStorage.setItem('webx11_validator_endpoint', endpoint);
this.ollamaManager.validatorEndpoint = endpoint;
this.ollamaManager.saveSettings();
} catch (e) {
if (statusEl) {
statusEl.innerHTML = '❌ Connection failed: ' + e.message +
'Make sure Ollama is running and ' +
'OLLAMA_ORIGINS includes this page\'s origin. ';
}
if (select) { select.disabled = true; select.innerHTML = 'Connection failed — try again '; }
}
}
initOllamaUI() {
// Initialize optimization toggle from stored state
const optCb = document.getElementById('ollama-optimization-toggle');
const optLabel = document.getElementById('ollama-optimization-label');
const optSlider = document.getElementById('ollama-optimization-slider');
if (optCb && this.ollamaManager) {
const enabled = this.ollamaManager.optimizationEnabled !== false;
optCb.checked = enabled;
if (optLabel) {
optLabel.textContent = enabled ? 'Enabled' : 'Disabled';
optLabel.style.color = enabled ? 'var(--success-color)' : 'var(--text-muted)';
}
if (optSlider) {
optSlider.style.background = enabled ? 'var(--accent-color)' : 'var(--bg-tertiary)';
optSlider.style.borderColor = enabled ? 'var(--success-color)' : 'var(--border-color)';
}
}
// Initialize validator toggle from stored state
const valCb = document.getElementById('ollama-validator-toggle');
const valLabel = document.getElementById('ollama-validator-label');
const valSlider = document.getElementById('ollama-validator-slider');
if (valCb && this.ollamaManager) {
const enabled = this.ollamaManager.validatorEnabled === true;
valCb.checked = enabled;
if (valLabel) {
valLabel.textContent = enabled ? 'Enabled' : 'Disabled';
valLabel.style.color = enabled ? 'var(--success-color)' : 'var(--text-muted)';
}
if (valSlider) {
valSlider.style.background = enabled ? 'var(--info-color)' : 'var(--bg-tertiary)';
valSlider.style.borderColor = enabled ? 'var(--info-color)' : 'var(--border-color)';
}
}
// Auto-populate validator model dropdown from connected models
this._populateValidatorModels();
// Initialize stream toggle from stored state
const strCb = document.getElementById('ollama-stream-toggle');
const strLabel = document.getElementById('ollama-stream-label');
const strSlider = document.getElementById('ollama-stream-slider');
if (strCb && this.ollamaManager) {
const enabled = this.ollamaManager.streamEnabled !== false;
strCb.checked = enabled;
if (strLabel) {
strLabel.textContent = enabled ? 'Enabled' : 'Disabled';
strLabel.style.color = enabled ? 'var(--success-color)' : 'var(--text-muted)';
}
if (strSlider) {
strSlider.style.background = enabled ? 'var(--accent-color)' : 'var(--bg-tertiary)';
strSlider.style.borderColor = enabled ? 'var(--success-color)' : 'var(--border-color)';
}
}
this.updateStreamIndicator();
// Populate validator model dropdown from stored config
const valSelect = document.getElementById('validator-model-select');
const valEndpoint = document.getElementById('validator-endpoint-input');
if (valEndpoint && this.ollamaManager) {
valEndpoint.value = this.ollamaManager.validatorEndpoint || 'http://localhost:11434';
}
if (valSelect && this.ollamaManager) {
const storedModel = this.ollamaManager.validatorModel || '';
if (storedModel) {
valSelect.innerHTML = '' + storedModel + ' Disable (use main AI model) ';
valSelect.disabled = false;
}
}
}
_populateValidatorModels() {
const select = document.getElementById('validator-model-select');
if (!select || !this.ollamaManager) return;
const models = this.ollamaManager.getModels();
if (!models || models.length === 0) {
select.innerHTML = 'Connect to load models ';
select.disabled = true;
return;
}
const stored = this.ollamaManager.validatorModel || '';
let html = 'Disable (use main AI model) ';
models.forEach(function(m) {
const sel = m.name === stored ? ' selected' : '';
html += '' + m.name + ' ';
});
select.innerHTML = html;
select.disabled = false;
}
openStartupModules() {
document.getElementById('startup-modules').style.display = 'flex';
this.renderStartupModules();
}
closeStartupModules() {
document.getElementById('startup-modules').style.display = 'none';
}
renderStartupModules() {
const body = document.getElementById('startup-modules-body');
const selected = (() => {
try {
const s = localStorage.getItem('webx11_startup_modules');
return s ? JSON.parse(s) : null;
} catch (e) { return null; }
})();
const isSel = (name) => !selected || selected.includes(name);
body.innerHTML = '';
const foundation = ['virtual-fs.js','module-manager.js','ollama-manager.js','terminal.js'];
const builtin = [
'module-system.js','module-fs.js','module-modmgr.js','module-ollama.js',
'module-my.js','module-searxng.js','module-servermon.js','module-ollamamodels.js',
'module-config.js','module-simplegui.js','module-io.js','module-dynamicprompts.js',
'module-aiassist.js','module-aiasm.js','module-smartctx.js','module-duckduckgo.js',
'module-fetcher.js','module-skills.js','module-chatstudio.js'
];
const extFiles = [
'calc.js','voice.js','cron.js','theme.js','wpm.js','mindmap.js','webdav.js','skill-pack-core.js','editor.js','diff.js','alias.js','finder.js','filemgr.js','cryptogzip.js','tutorial.js','shell.js','busybox.js'
];
const addSection = (title) => {
const h = document.createElement('div');
h.className = 'mod-section-header';
h.textContent = title;
body.appendChild(h);
};
const addCard = (name, badge, isDisabled) => {
const card = document.createElement('div');
card.className = 'mod-card' + (isDisabled ? ' disabled' : '');
const label = document.createElement('label');
const cb = document.createElement('input');
cb.type = 'checkbox';
cb.checked = isDisabled || isSel(name);
cb.disabled = isDisabled;
cb.dataset.name = name;
const nameSpan = document.createElement('span');
nameSpan.className = 'mod-name';
nameSpan.textContent = name;
const badgeSpan = document.createElement('span');
badgeSpan.className = 'mod-badge' + (badge === 'foundation' ? ' foundation' : badge === 'ext' ? ' ext' : '');
badgeSpan.textContent = badge;
label.appendChild(cb);
label.appendChild(nameSpan);
card.appendChild(label);
card.appendChild(badgeSpan);
body.appendChild(card);
};
addSection('Foundation (always loaded)');
foundation.forEach(n => addCard(n, 'foundation', true));
addSection('Built-in modules');
builtin.forEach(n => addCard(n, 'built-in', false));
addSection('Extensions');
extFiles.forEach(n => addCard(n, 'ext', false));
}
saveStartupModules() {
const cbs = document.querySelectorAll('#startup-modules-body input[type="checkbox"]:not(:disabled)');
const selected = [];
cbs.forEach(cb => {
if (cb.checked) selected.push(cb.dataset.name);
});
try {
localStorage.setItem('webx11_startup_modules', JSON.stringify(selected));
} catch (e) {
this.print('Failed to save startup modules', 'error');
return;
}
this.print('Startup modules saved, reloading...', 'success');
setTimeout(() => location.reload(), 500);
}
openCodeEditor(name, url, code) {
const modal = document.getElementById('code-editor');
const title = document.getElementById('code-editor-title');
const info = document.getElementById('code-editor-info');
const ta = document.getElementById('code-editor-textarea');
if (!modal || !ta) return;
this._editingModule = { name: name, url: url };
title.textContent = '📝 Edit: ' + name;
info.textContent = 'Module: ' + name + (url ? ' | Source: ' + url : '');
ta.value = code;
modal.style.display = 'flex';
}
closeCodeEditor() {
document.getElementById('code-editor').style.display = 'none';
this._editingModule = null;
}
async loadLocalModule(path) {
if (!path) {
this.print('Usage: load-local ', 'warning');
this.print('Loads a module from VFS (e.g. "load-local modules/ext/mindmap.js") or from URL', 'info');
return;
}
let code = this.fs.readFile(path);
if (code === null) {
try {
const resp = await fetch(path);
if (!resp.ok) throw new Error('HTTP ' + resp.status);
code = await resp.text();
} catch (e) {
this.print('load-local: file not found in VFS or at URL: ' + path, 'error');
return;
}
}
try {
const el = document.createElement('script');
el.textContent = code;
el.onerror = () => this.print('load-local: failed to execute ' + path, 'error');
document.body.appendChild(el);
this.print('Module loaded from ' + path, 'success');
} catch (e) {
this.print('load-local: error: ' + e.message, 'error');
}
}
async saveCodeEditor() {
const ta = document.getElementById('code-editor-textarea');
const mod = this._editingModule;
if (!mod || !ta) return;
const code = ta.value;
this.fs.writeFile('_mods/' + mod.name + '.js', code);
if (!this.fs.readFile('_mods/' + mod.name + '.orig.js')) {
this.fs.writeFile('_mods/' + mod.name + '.orig.js', 'placeholder');
}
this.fs.writeFile('_mods/' + mod.name + '.meta.json', JSON.stringify({
originalUrl: mod.url || '',
modifiedAt: new Date().toISOString(), editCount: 1
}));
try {
const ok = await this.moduleManager.loadModuleFromCode(code, 'blob://_mods/' + mod.name + '.js');
if (ok) {
this.print('✅ Module loaded live from _mods/' + mod.name + '.js', 'success');
} else {
this.print('⚠️ Auto-test failed — edits saved but module not loaded', 'warning');
}
} catch (e) {
this.print('⚠️ Auto-test failed: ' + e.message + ' — edits saved but module not loaded', 'error');
}
this.closeCodeEditor();
}
_restoreFontSize() {
try {
const saved = localStorage.getItem('webx11_font_size');
if (saved) {
const s = parseInt(saved);
if (s >= 8 && s <= 32) {
document.body.style.fontSize = s + 'px';
const tc = document.getElementById('terminal-container');
if (tc) tc.style.fontSize = s + 'px';
const inp = document.getElementById('command-input');
if (inp) inp.style.fontSize = s + 'px';
}
}
} catch (e) {}
}
_restoreTheme() {
try {
const savedTheme = localStorage.getItem('webx11_active_theme');
if (savedTheme) {
const themesRaw = localStorage.getItem('webx11_themes');
const defaults = {
'dark': { '--bg-color': '#000000', '--bg-secondary': '#0a0a0a', '--bg-tertiary': '#111111', '--text-color': '#00ff00', '--text-muted': '#888888', '--accent-color': '#00aa00', '--border-color': '#005500' },
'light': { '--bg-color': '#ffffff', '--bg-secondary': '#f5f5f5', '--bg-tertiary': '#eeeeee', '--text-color': '#222222', '--text-muted': '#888888', '--accent-color': '#0066cc', '--border-color': '#cccccc' },
'amber': { '--bg-color': '#1a0f00', '--bg-secondary': '#2a1a00', '--bg-tertiary': '#332200', '--text-color': '#ffaa00', '--text-muted': '#886600', '--accent-color': '#ff8800', '--border-color': '#663300' },
'cyber': { '--bg-color': '#000a14', '--bg-secondary': '#001a2e', '--bg-tertiary': '#002240', '--text-color': '#00ffff', '--text-muted': '#0088aa', '--accent-color': '#ff00ff', '--border-color': '#004466' },
'ocean': { '--bg-color': '#0a1628', '--bg-secondary': '#0f2240', '--bg-tertiary': '#142e54', '--text-color': '#88ddff', '--text-muted': '#5599bb', '--accent-color': '#00aaff', '--border-color': '#1a4470' },
'forest': { '--bg-color': '#0a1a0e', '--bg-secondary': '#0f2814', '--bg-tertiary': '#14361c', '--text-color': '#88dd88', '--text-muted': '#559955', '--accent-color': '#33cc33', '--border-color': '#1a4a22' },
'monochrome': { '--bg-color': '#0a0a0a', '--bg-secondary': '#181818', '--bg-tertiary': '#222222', '--text-color': '#cccccc', '--text-muted': '#666666', '--accent-color': '#999999', '--border-color': '#333333' },
'sunset': { '--bg-color': '#1a0a14', '--bg-secondary': '#2a1220', '--bg-tertiary': '#3a1a2c', '--text-color': '#ffbb99', '--text-muted': '#cc7755', '--accent-color': '#ff6644', '--border-color': '#552233' },
'midnight': { '--bg-color': '#050510', '--bg-secondary': '#0a0a20', '--bg-tertiary': '#101030', '--text-color': '#aaaaee', '--text-muted': '#555588', '--accent-color': '#6666ff', '--border-color': '#1a1a3a' }
};
if (themesRaw) {
const themes = JSON.parse(themesRaw);
Object.assign(defaults, themes);
}
const t = defaults[savedTheme];
if (t) {
for (const [k, v] of Object.entries(t)) {
document.documentElement.style.setProperty(k, v);
}
}
}
} catch (e) { console.warn('[Theme] restore error:', e); }
}
// ── First-run welcome ──
_checkFirstRun() {
if (localStorage.getItem('webx11_tutorial_complete') || localStorage.getItem('webx11_tutorial_skipped')) return;
// Check if tutorial module is loaded — retry if ext modules not ready yet
const tutMod = this.moduleManager.modules['TutorialModule'];
if (!tutMod) {
// Ext modules may still be loading; retry in 2s
if (!this._welcomeRetry) {
this._welcomeRetry = true;
setTimeout(() => this._checkFirstRun(), 2000);
}
return;
}
this.tutorialModule = tutMod;
// Only show welcome if user hasn't explicitly dismissed it
if (!localStorage.getItem('webx11_welcome_dismissed')) {
this.showWelcomeModal();
}
}
showWelcomeModal() {
const modal = document.getElementById('welcome-modal');
if (modal) {
modal.style.display = 'flex';
this._welcomeShown = true;
}
}
dismissWelcome() {
const modal = document.getElementById('welcome-modal');
if (modal) modal.style.display = 'none';
localStorage.setItem('webx11_welcome_dismissed', '1');
// Also mark tutorial as skipped so it doesn't reappear
if (this.tutorialModule) this.tutorialModule.skipTutorial();
}
toggleCmdCards() {
const panel = document.getElementById('cmd-cards');
if (!panel) return;
panel.classList.toggle('visible');
// If opening, focus the input field
if (panel.classList.contains('visible')) {
setTimeout(() => this.inputField.focus(), 50);
}
}
log(level, source, message) {
const d = new Date();
const ts = d.toISOString().split('T')[1].split('.')[0];
const entry = `[${ts}][${level}][${source}] ${message}`;
console.log(entry);
// Write to VFS log
try {
this.fs.mkdirRecursive('/logs');
let current = this.fs.readFile('/logs/current.log') || '';
current += entry + '\n';
const lines = current.split('\n').filter(l => l);
if (lines.length > 200) {
this.fs.writeFile('/logs/old.log', lines.slice(0, -100).join('\n'));
this.fs.writeFile('/logs/current.log', lines.slice(-100).join('\n'));
} else {
this.fs.writeFile('/logs/current.log', current);
}
} catch (e) {}
}
setAiBusy(busy) {
this._aiBusy = !!busy;
if (this._aiBusyInterval) {
clearInterval(this._aiBusyInterval);
this._aiBusyInterval = null;
}
const cancelBtn = document.getElementById('ai-cancel-btn');
if (busy) {
if (cancelBtn) cancelBtn.style.display = 'inline-block';
this._aiBusyStart = Date.now();
const updateTimer = () => {
let elapsed = Math.round((Date.now() - this._aiBusyStart) / 1000);
let sec = elapsed % 60;
let min = Math.floor(elapsed / 60);
this.statusBar.textContent = '🤖 AI Thinking... ⏱ ' + (min > 0 ? min + 'm ' : '') + sec + 's';
this.statusBar.className = 'loading';
};
updateTimer();
this._aiBusyInterval = setInterval(updateTimer, 1000);
} else {
if (cancelBtn) cancelBtn.style.display = 'none';
this.statusBar.textContent = 'Ready';
this.statusBar.className = '';
this._aiBusyStart = 0;
}
}
printMemoryStats() {
const pm = typeof performance !== 'undefined' && performance.memory;
let heapInfo = '';
if (pm) {
const used = (pm.usedJSHeapSize / 1048576).toFixed(1);
const total = (pm.totalJSHeapSize / 1048576).toFixed(1);
const limit = (pm.jsHeapSizeLimit / 1048576).toFixed(1);
heapInfo = `JS heap: ${used}MB / ${total}MB (limit ${limit}MB)`;
} else {
try {
const lsSize = (new Blob([JSON.stringify(localStorage)]).size / 1024).toFixed(1);
heapInfo = `localStorage: ${lsSize}KB`;
} catch (e) {
heapInfo = 'Memory API not available on this browser';
}
}
const output = [
`📊 Memory Stats`,
` ${heapInfo}`,
` Terminal lines in DOM: ${this.outputContainer.children.length}`,
` History buffer: ${this._historyBuffer.length} entries`,
` MAX_OUTPUT_LINES: ${this.MAX_OUTPUT_LINES}`,
` Commands in history: ${this.history.length}`
].join('\n');
this.print(output, 'info');
}
playNotificationSound() {
if (localStorage.getItem('webx11_config_ai_sound') === 'off') return;
try {
const ctx = new (window.AudioContext || window.webkitAudioContext)();
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.connect(gain);
gain.connect(ctx.destination);
osc.frequency.value = 880;
osc.type = 'sine';
gain.gain.setValueAtTime(0.15, ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.3);
osc.start(ctx.currentTime);
osc.stop(ctx.currentTime + 0.3);
} catch (e) {}
}
toggleMicVisibility() {
const voiceMod = this.moduleManager.modules['VoiceModule'];
if (!voiceMod) {
this.print('Voice module not loaded', 'warning');
return;
}
if (voiceMod.listening) {
voiceMod.stopListening();
} else {
voiceMod.handle([]);
}
}
setFontSize(size) {
const s = parseInt(size);
if (isNaN(s) || s < 8 || s > 32) {
this.print('Usage: font-size <8-32>', 'warning');
return;
}
document.body.style.fontSize = s + 'px';
document.getElementById('terminal-container').style.fontSize = s + 'px';
document.getElementById('command-input').style.fontSize = s + 'px';
try { localStorage.setItem('webx11_font_size', String(s)); } catch (e) {}
this.print('Font size set to ' + s + 'px 👆', 'success');
}
}