// Extracted from newOS4 index.html โ€” WebX11Terminal 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.ollamaManager = new OllamaManager(this); this._pipeInput = null; this._pipeBufferSize = 5000; this._historyBuffer = []; this._historyMaxLines = 50; this._historySavePending = false; this.MAX_OUTPUT_LINES = 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.initEventListeners(); this.updateNetworkStatus(); this.loadBuiltInModules(); this._loadHistory(); // Auto-restore session this._autoRestoreSession(); this._restoreFontSize(); // 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); } 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; this.history.push(cmd); 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.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'); line.className = `output-line ${type}`; 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(); } printAIResponse(text) { const wrapper = document.createElement('div'); wrapper.className = 'output-line ollama'; const safeText = text.replace(//g, ">"); wrapper.innerHTML = `
${safeText}
`; this.outputContainer.appendChild(wrapper); this.scrollToBottom(); this._trimOutput(); } printStreamingResponse(text, append) { if (!append) { const safeText = text.replace(//g, ">"); const wrapper = document.createElement('div'); wrapper.className = 'output-line ollama'; wrapper.innerHTML = `
${safeText}
`; this.outputContainer.appendChild(wrapper); } else { const contentDiv = this.outputContainer.querySelector('.output-line.ollama:last-child .ai-content'); if (contentDiv) { const safeText = text.replace(//g, ">"); contentDiv.textContent += safeText; } } this.scrollToBottom(); this._trimOutput(); } copyToClipboard(btn) { const contentDiv = btn.nextElementSibling; const text = contentDiv.innerText; navigator.clipboard.writeText(text).then(() => { const originalText = btn.textContent; btn.textContent = 'Copied!'; setTimeout(() => { btn.textContent = originalText; }, 2000); }).catch(err => { console.error('Failed to copy: ', err); }); } 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(); } _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 doBatch = (index) => { if (index >= remaining.length) { 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); }); }; doBatch(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(-30); 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); } _autoSaveSession() { try { this.fs.mkdirRecursive('_sessions'); // Clone fsRoot and strip _sessions to prevent recursive nesting var rootClone = JSON.parse(JSON.stringify(this.fs.root)); 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) }; this.fs.writeFile('_sessions/_auto.json', JSON.stringify(snapshot, null, 2)); // Also persist AI context concurrently 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; } showAutocomplete() { const input = this.inputField.value; const trimmed = input.trim(); const commands = this.moduleManager.getAllCommands(); 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(); // Commands that take file/dir arguments for autocomplete const fileArgCommands = ['cat', 'preview', 'rm', 'rmdir', 'edit', 'view']; const dirArgCommands = ['ls', 'cd']; // Session commands that autocomplete from _sessions/*.json const sessionArgCommands = ['session-load', 'session-delete', 'session-export', 'session-save']; // 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; } // Determine if we should show session names, dirs only, or both if (sessionArgCommands.includes(cmdPart)) { this.fs.mkdirRecursive('_sessions'); const all = this.fs.ls('_sessions'); this.suggestions = all .filter(e => e.type === 'file' && e.name.endsWith('.json')) .map(e => e.name.replace('.json', '')) .filter(n => n.startsWith(partialTrimmed)); } else if (cmdPart === 'cd' || cmdPart === 'rmdir' || dirArgCommands.includes(cmdPart)) { // Dir-only commands const entries = this.fs.ls().filter(f => f.type === 'dir'); this.suggestions = entries .filter(e => e.name.startsWith(partialTrimmed)) .map(e => e.name + '/'); } else if (fileArgCommands.includes(cmdPart)) { const all = this.fs.ls(); this.suggestions = all .filter(e => e.name.startsWith(partialTrimmed)) .map(e => e.type === 'dir' ? e.name + '/' : e.name); } else { this.hideAutocomplete(); return; } this._autocompletePrefix = cmdPart + ' '; if (this.suggestions.length > 0) { this._isCdAutocomplete = (cmdPart === 'cd' || cmdPart === 'rmdir'); 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.fs.mkdirRecursive('_sessions'); const all = this.fs.ls('_sessions'); this.suggestions = all .filter(e => e.type === 'file' && e.name.endsWith('.json')) .map(e => e.name.replace('.json', '')); } else if (cmdPart === 'cd' || cmdPart === 'rmdir' || dirArgCommands.includes(cmdPart)) { const dirs = this.fs.ls().filter(f => f.type === 'dir'); this.suggestions = dirs.map(d => d.name + '/'); } else if (fileArgCommands.includes(cmdPart)) { const all = this.fs.ls(); this.suggestions = all.map(e => e.type === 'dir' ? e.name + '/' : e.name); } else { this.hideAutocomplete(); return; } this._autocompletePrefix = cmdPart + ' '; if (this.suggestions.length > 0) { this._isCdAutocomplete = (cmdPart === 'cd' || cmdPart === 'rmdir'); this.renderAutocomplete(); } else { this.hideAutocomplete(); } return; } // Directory autocomplete for bare 'cd ' (no partial yet) โ€” should be unreachable due to above block if (lowerInput.startsWith('cd ')) { const dirs = this.fs.ls().filter(f => f.type === 'dir'); this.suggestions = dirs.map(d => d.name + '/'); 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('SkillsModule', 'skills'); this._safeRegister('SecretVault', 'secret vault'); this.reloadPersistedModules(); this._safeRegister('ChatStudioModule', 'chat studio'); } _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}`); } } async _loadExtModules(filterKeys) { const autoLoadKeys = filterKeys || ['calc', 'voice', 'cron', 'theme', 'wpm', 'mindmap', 'webdav', 'skill-pack-core', 'editor', 'diff', 'alias', 'finder']; try { const res = await fetch('packages.json'); 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) { await this.moduleManager.loadModuleFromURL(pkg.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']; 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(); this.print(`webx11@module:~$ ${rawInput}`, 'command-prompt'); const pipeParts = this.splitPipe(rawInput.trim()); if (pipeParts.length > 1) { await this.executePipeChain(pipeParts); return; } const parts = rawInput.trim().split(/\s+/); const cmd = parts[0].toLowerCase(); const args = parts.slice(1); 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 === 'clear') { this.outputContainer.innerHTML = ''; this._historyBuffer = []; localStorage.removeItem('webx11_terminal_output'); return; } let executed = false; 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); executed = true; } catch (err) { this.print(`Error in ${modName}.${cmd}: ${err.message}`, 'error'); } break; } } if (!executed) { // Route unknown commands to the AI agent try { const aiMod = this.moduleManager.modules['WebX11AIAssistantModule']; if (aiMod) { this.print('โ†’', 'info'); await aiMod.chat([rawInput.trim()]); return; } } catch (e) { this.print(`Agent error: ${e.message}`, 'error'); return; } } } 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 = cmdText.split(/\s+/); 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; } 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); return; } catch (err) { this.print(`Error in ${modName}.${cmd}: ${err.message}`, 'error'); return; } } } // Route unknown commands to the AI agent try { const aiMod = this.moduleManager.modules['WebX11AIAssistantModule']; if (aiMod) { this.print('โ†’ Routing to AI assistant...', 'info'); await aiMod.chat([cmd + ' ' + args.join(' ')]); return; } } catch (e) { this.print(`Agent error: ${e.message}`, 'error'); } 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(); 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.fsRead('/' + 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(); } openSkillsManager() { const modal = document.getElementById('skills-manager'); // Ensure /skills/ directory exists const skillsMod = this.moduleManager.modules['SkillsModule']; if (skillsMod && skillsMod.ensureSkillsDir) skillsMod.ensureSkillsDir(); 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 `
${e.key} ${displayVal}
`; }).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.fsRead(`/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 = 'block'; } closeSkillPreview() { document.getElementById('skill-preview').style.display = 'none'; } deleteSkillUI(name) { if (!confirm(`Delete skill "${name}"?`)) return; const fs = this.fs; fs.mkdirRecursive('/skills'); fs.fsWrite(`/skills/${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(); } 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.fsRead(`/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.fsWrite(`/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'); container.innerHTML = ''; select.innerHTML = ''; const models = this.ollamaManager.getModels(); if (models.length === 0) { container.innerHTML = '
Connect to see models
'; return; } models.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(); this.updateOllamaStatus(); if (success) { this.renderOllamaModels(); } } setOllamaModel() { const select = document.getElementById('ollama-current-model'); this.ollamaManager.setModel(select.value); } 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','skill-pack-core.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 { await this.moduleManager.loadModuleFromCode(code, 'blob://_mods/' + mod.name + '.js'); this.print('โœ… Module loaded live from _mods/' + mod.name + '.js', 'success'); } 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.documentElement.style.fontSize = s + 'px'; } } catch (e) {} } 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.documentElement.style.fontSize = s + 'px'; try { localStorage.setItem('webx11_font_size', String(s)); } catch (e) {} this.print('Font size set to ' + s + 'px', 'success'); } }