diff options
Diffstat (limited to 'modules/core/terminal.js')
| -rw-r--r-- | modules/core/terminal.js | 1362 |
1 files changed, 1362 insertions, 0 deletions
diff --git a/modules/core/terminal.js b/modules/core/terminal.js new file mode 100644 index 0000000..0a9538f --- /dev/null +++ b/modules/core/terminal.js @@ -0,0 +1,1362 @@ +// 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.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, "<").replace(/>/g, ">"); + wrapper.innerHTML = ` + <button class="copy-btn" onclick="webx11.copyToClipboard(this)">Copy</button> + <div class="ai-content">${safeText}</div> + `; + this.outputContainer.appendChild(wrapper); + this.scrollToBottom(); + this._trimOutput(); + } + printStreamingResponse(text, append) { + if (!append) { + const safeText = text.replace(/</g, "<").replace(/>/g, ">"); + const wrapper = document.createElement('div'); + wrapper.className = 'output-line ollama'; + wrapper.innerHTML = ` + <button class="copy-btn" onclick="webx11.copyToClipboard(this)">Copy</button> + <div class="ai-content">${safeText}</div> + `; + this.outputContainer.appendChild(wrapper); + } else { + const contentDiv = this.outputContainer.querySelector('.output-line.ollama:last-child .ai-content'); + if (contentDiv) { + const safeText = text.replace(/</g, "<").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, '<').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() { + 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) + } + } + _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'); + const snapshot = { + type: 'session', + savedAt: new Date().toISOString(), + fsRoot: this.fs.root, + 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 + ? `<span class="cmd-name" style="color:var(--accent-color)">${item}</span>` + : `<span class="cmd-name">${item}</span>`; + 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 = '<div class="text-center" style="padding:20px; color:#666">Directory Empty</div>'; + } else { + files.forEach(f => { + const div = document.createElement('div'); + div.className = `file-item ${f.type === 'dir' ? 'dir' : ''}`; + div.innerHTML = `<span>${f.type === 'dir' ? '📁' : '📄'} ${f.name}</span> <span style="font-size:0.8em">${f.size}b</span>`; + 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 = '<div class="text-center" style="padding:20px; color:#666">No modules loaded</div>'; + return; + } + modules.forEach(mod => { + const div = document.createElement('div'); + div.className = `module-item ${mod.loaded ? '' : 'disabled'}`; + const badge = mod.url ? '' : '<span class="module-badge builtin">Built-in</span>'; + div.innerHTML = ` + <div> + <strong>${mod.name}</strong>${badge} + <div style="font-size:0.8em; color:#666">${mod.url || 'Built-in'}</div> + </div> + ${mod.url ? `<button class="btn btn-danger" onclick="webx11.removeModule('${mod.name}')" style="padding:4px 8px;">✕</button>` : ''} + `; + 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 <file.html>', '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 = '<div style="padding:20px; text-align:center; color:#666">No configuration saved.</div>'; + 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 `<div class="module-item"> + <div><strong>${e.key}</strong> <span style="color:var(--text-muted)">${displayVal}</span></div> + <button class="btn btn-danger" onclick="webx11.deleteConfigFromUI('${e.key.replace(/'/g, "\\'")}')" style="padding:4px 8px; font-size:0.8em">✕</button> + </div>`; + }).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 = '<div style="padding:20px; text-align:center; color:#666">No skills yet. Create one below!</div>'; + return; + } + names.forEach(name => { + const s = index[name]; + const tags = (s.tags || []).join(', '); + const div = document.createElement('div'); + div.className = 'module-item'; + div.innerHTML = ` + <div> + <strong style="color:var(--success-color)">${name}</strong> + <div style="font-size:0.85em; color:#aaa">${s.description || ''}</div> + <div style="font-size:0.75em; color:#666">${s.category || 'general'}${tags ? ' · ' + tags : ''}</div> + </div> + <div style="display:flex;gap:4px"> + <button class="btn" onclick="webx11.viewSkillUI('${name}')" style="padding:4px 8px; font-size:0.8em">👁</button> + <button class="btn btn-danger" onclick="webx11.deleteSkillUI('${name}')" style="padding:4px 8px; font-size:0.8em">✕</button> + </div> + `; + 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 = '<option value="">Select a model</option>'; + const models = this.ollamaManager.getModels(); + if (models.length === 0) { + container.innerHTML = '<div class="text-center" style="padding:20px; color:#666">Connect to see models</div>'; + return; + } + models.forEach(model => { + const div = document.createElement('div'); + div.className = 'model-item'; + div.innerHTML = `<span>${model.name}</span><span style="font-size:0.8em; color:#666">${model.size ? (model.size / 1e9).toFixed(1) + 'GB' : ''}</span>`; + 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 <path>', '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'); + } + } |