aboutsummaryrefslogtreecommitdiff
path: root/modules/core/terminal.js
diff options
context:
space:
mode:
authorAgando <roduccomm@gmail.com>2026-05-25 18:02:02 +0200
committerAgando <roduccomm@gmail.com>2026-05-25 18:02:02 +0200
commit4baa484b84c5184286cac2743575251454a5ee2b (patch)
treecb9002f93644f363d0f35831dc97e6492bbb0ed6 /modules/core/terminal.js
auto: 2026-05-25_18:02 changes
Diffstat (limited to 'modules/core/terminal.js')
-rw-r--r--modules/core/terminal.js1362
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, "&lt;").replace(/>/g, "&gt;");
+ 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, "&lt;").replace(/>/g, "&gt;");
+ 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, "&lt;").replace(/>/g, "&gt;");
+ 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, '&lt;').replace(/>/g, '&gt;');
+ 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');
+ }
+ }