// Extracted from newOS4 index.html โ WebX11Terminal
class WebX11Terminal {
constructor() {
this.outputContainer = document.getElementById('terminal-container');
this.inputField = document.getElementById('command-input');
this.dropdown = document.getElementById('autocomplete-dropdown');
this.statusBar = document.getElementById('status-text');
this.networkStatus = document.getElementById('network-status');
this.fs = new VirtualFileSystem();
this.secretVault = null;
try {
if (typeof SecretVault !== 'undefined') {
this.secretVault = new SecretVault(this);
window.webx11_secretVault = this.secretVault;
}
} catch (e) { console.warn('SecretVault init:', e.message); }
this.moduleManager = new ModuleManager(this);
this.ollamaManager = new OllamaManager(this);
this._pipeInput = null;
this._pipeBufferSize = 5000;
this._historyBuffer = [];
this._historyMaxLines = 50;
this._historySavePending = false;
this.MAX_OUTPUT_LINES = 200;
this._aiBusy = false;
this._dirty = false;
this.history = JSON.parse(localStorage.getItem('webx11_command_history') || '[]');
this.historyIndex = this.history.length;
this.selectedSuggestion = -1;
this.suggestions = [];
this._isCdAutocomplete = false;
this._autocompletePrefix = '';
this.initEventListeners();
this.updateNetworkStatus();
this.loadBuiltInModules();
this._loadHistory();
// Auto-restore session
this._autoRestoreSession();
this._restoreFontSize();
// Init auto-save timer โ 60s after boot, then every 60s
setTimeout(() => this._initAutoSave(), 30000);
// Init sound toggle button text
const soundBtn = document.getElementById('sound-toggle-btn');
if (soundBtn) {
soundBtn.textContent = localStorage.getItem('webx11_config_ai_sound') === 'off' ? '๐ Muted' : '๐ Sound';
}
setTimeout(() => this.inputField.focus(), 100);
}
initEventListeners() {
this.inputField.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
if (this.selectedSuggestion >= 0 && this.suggestions[this.selectedSuggestion]) {
if (this._autocompletePrefix) {
this.inputField.value = this._autocompletePrefix + this.suggestions[this.selectedSuggestion];
} else if (!this._isCdAutocomplete) {
this.inputField.value = this.suggestions[this.selectedSuggestion];
}
}
const cmd = this.inputField.value;
this.history.push(cmd);
this.historyIndex = this.history.length;
this._saveCommandHistory();
this.executeCommand(cmd);
this.inputField.value = '';
this.inputField.style.height = 'auto';
this.hideAutocomplete();
} else if (e.key === 'ArrowUp') {
e.preventDefault();
if (this.historyIndex > 0) {
this.historyIndex--;
this.inputField.value = this.history[this.historyIndex];
}
this.hideAutocomplete();
} else if (e.key === 'ArrowDown') {
e.preventDefault();
if (this.historyIndex < this.history.length - 1) {
this.historyIndex++;
this.inputField.value = this.history[this.historyIndex];
} else {
this.historyIndex = this.history.length;
this.inputField.value = '';
}
this.hideAutocomplete();
} else if (e.key === 'Tab') {
e.preventDefault();
if (this.suggestions.length > 0) {
this.selectedSuggestion = (this.selectedSuggestion + 1) % this.suggestions.length;
this.updateAutocompleteDisplay();
}
} else if (e.key === 'Escape') {
if (this._aiBusy) {
this.executeCommand('ai-stop');
} else {
this.hideAutocomplete();
}
}
});
this.inputField.addEventListener('input', () => {
this.inputField.style.height = 'auto';
this.inputField.style.height = Math.min(this.inputField.scrollHeight, 120) + 'px';
this.showAutocomplete();
});
this.inputField.addEventListener('focus', () => {
this.showAutocomplete();
});
document.addEventListener('click', (e) => {
if (!e.target.closest('#input-area')) {
this.hideAutocomplete();
}
});
window.addEventListener('online', () => this.updateNetworkStatus());
window.addEventListener('offline', () => this.updateNetworkStatus());
document.getElementById('file-upload-input').addEventListener('change', (e) => {
const file = e.target.files[0];
if (file) {
const reader = new FileReader();
reader.onload = (evt) => {
this.fs.writeFile(file.name, evt.target.result);
this.markDirty();
this.print(`File '${file.name}' uploaded successfully.`, 'success');
};
reader.readAsText(file);
}
e.target.value = '';
});
// Voice mic button
// Sound toggle button
const soundBtn = document.getElementById('sound-toggle-btn');
if (soundBtn) {
soundBtn.addEventListener('click', () => {
const current = localStorage.getItem('webx11_config_ai_sound');
if (current === 'off') {
localStorage.setItem('webx11_config_ai_sound', 'on');
soundBtn.textContent = '๐ Sound';
} else {
localStorage.setItem('webx11_config_ai_sound', 'off');
soundBtn.textContent = '๐ Muted';
}
});
}
// Mic toggle button
const micToggleBtn = document.getElementById('voice-toggle-btn');
if (micToggleBtn) {
micToggleBtn.addEventListener('click', () => this.toggleMicVisibility());
}
}
updateNetworkStatus() {
const isOnline = navigator.onLine;
this.networkStatus.textContent = isOnline ? '๐ถ Online' : 'โ Offline';
this.networkStatus.style.color = isOnline ? 'var(--text-color)' : 'var(--error-color)';
}
updatePrompt() {
const path = '/' + this.fs.currentPath.join('/');
const label = document.querySelector('.prompt-label');
if (!label) return;
if (localStorage.getItem('webx11_smart_prompt') === 'off') {
label.textContent = path === '/' ? '$' : path + ' $';
return;
}
const indicators = [];
try {
const p = localStorage.getItem('webx11_user_profile');
if (p) { const pf = JSON.parse(p); if (pf.knownFacts && pf.knownFacts.name) indicators.push('๐ค' + pf.knownFacts.name); }
} catch (e) {}
try {
const idx = this.fs.fsReadJSON('/skills/index.json');
if (idx) { const c = Object.keys(idx).length; if (c > 0) indicators.push('๐' + c); }
} catch (e) {}
try {
const memIdx = this.fs.fsReadJSON('/memory/index.json');
if (memIdx) { const c = Object.keys(memIdx).length; if (c > 0) indicators.push('๐ง ' + c); }
} catch (e) {}
try {
if (performance.memory && performance.memory.usedJSHeapSize > 80 * 1048576) {
indicators.push('๐พ' + (performance.memory.usedJSHeapSize / 1048576).toFixed(0) + 'MB');
}
} catch (e) {}
try {
const md = this.fs.readDir('_mods');
if (md && md.content) { const e = Object.keys(md.content).filter(k => k.endsWith('.meta.json')); if (e.length > 0) indicators.push('โก' + e.length); }
} catch (e) {}
const prefix = indicators.length ? indicators.join(' ') + ' ' : '';
label.textContent = prefix + (path === '/' ? '$' : path + ' $');
}
print(text, type = '') {
const line = document.createElement('div');
line.className = `output-line ${type}`;
line.textContent = text;
this.outputContainer.appendChild(line);
this._historyBuffer.push({ l: text, t: type });
if (this._historyBuffer.length > this._historyMaxLines) {
this._historyBuffer.shift();
}
this._saveHistory();
this.scrollToBottom();
this._trimOutput();
}
printAIResponse(text) {
const wrapper = document.createElement('div');
wrapper.className = 'output-line ollama';
const safeText = text.replace(//g, ">");
wrapper.innerHTML = `
${safeText}
`;
this.outputContainer.appendChild(wrapper);
this.scrollToBottom();
this._trimOutput();
}
printStreamingResponse(text, append) {
if (!append) {
const safeText = text.replace(//g, ">");
const wrapper = document.createElement('div');
wrapper.className = 'output-line ollama';
wrapper.innerHTML = `
${safeText}
`;
this.outputContainer.appendChild(wrapper);
} else {
const contentDiv = this.outputContainer.querySelector('.output-line.ollama:last-child .ai-content');
if (contentDiv) {
const safeText = text.replace(//g, ">");
contentDiv.textContent += safeText;
}
}
this.scrollToBottom();
this._trimOutput();
}
copyToClipboard(btn) {
const contentDiv = btn.nextElementSibling;
const text = contentDiv.innerText;
navigator.clipboard.writeText(text).then(() => {
const originalText = btn.textContent;
btn.textContent = 'Copied!';
setTimeout(() => { btn.textContent = originalText; }, 2000);
}).catch(err => { console.error('Failed to copy: ', err); });
}
printClickable(items) {
const line = document.createElement('div');
line.className = 'output-line';
items.forEach(f => {
const span = document.createElement('span');
const safeName = f.name.replace(//g, '>');
if (f.type === 'dir') {
span.className = 'clickable-dir';
span.textContent = '[dir]' + f.name + '/';
span.onclick = () => {
this.executeCommand('cd ' + f.name);
setTimeout(() => this.executeCommand('ls'), 50);
};
} else {
span.className = 'clickable-file';
span.textContent = ' ' + f.name;
span.onclick = () => this.executeCommand('cat ' + f.name);
}
line.appendChild(span);
});
this.outputContainer.appendChild(line);
this.scrollToBottom();
}
printHTML(html) {
const line = document.createElement('div');
line.className = 'output-line';
line.innerHTML = html;
this.outputContainer.appendChild(line);
this.scrollToBottom();
}
_loadHistory() {
try {
const saved = localStorage.getItem('webx11_terminal_output');
if (!saved) return;
const entries = JSON.parse(saved);
if (!Array.isArray(entries)) return;
this._historyBuffer = entries.slice(-this._historyMaxLines);
// Check localStorage quota warning
let quotaWarned = false;
try {
const testKey = '_quota_test_' + Date.now();
localStorage.setItem(testKey, 'x'.repeat(1024));
localStorage.removeItem(testKey);
} catch (e) {
quotaWarned = true;
}
// Lazy-load history: render first 20 immediately, batch the rest
const batchSize = 20;
const toRender = entries.slice(-this._historyMaxLines);
const initial = toRender.slice(0, batchSize);
for (const entry of initial) {
const line = document.createElement('div');
line.className = `output-line ${entry.t || ''}`;
line.textContent = entry.l || '';
this.outputContainer.appendChild(line);
}
if (toRender.length > batchSize) {
const remaining = toRender.slice(batchSize);
const doBatch = (index) => {
if (index >= remaining.length) {
this.scrollToBottom();
return;
}
requestAnimationFrame(() => {
const end = Math.min(index + batchSize, remaining.length);
for (let i = index; i < end; i++) {
const line = document.createElement('div');
line.className = `output-line ${remaining[i].t || ''}`;
line.textContent = remaining[i].l || '';
this.outputContainer.appendChild(line);
}
doBatch(end);
});
};
doBatch(0);
} else {
this.scrollToBottom();
}
if (quotaWarned) {
const warnLine = document.createElement('div');
warnLine.className = 'output-line warning';
warnLine.textContent = 'โ localStorage is near quota โ history may be incomplete';
this.outputContainer.appendChild(warnLine);
}
} catch (e) {
// Silently ignore corrupt history data
}
}
_saveHistory() {
if (this._historySavePending) return;
this._historySavePending = true;
setTimeout(() => {
this._historySavePending = false;
try {
const slice = this._historyBuffer.slice(-30);
localStorage.setItem('webx11_terminal_output', JSON.stringify(slice));
} catch (e) {
// Silently ignore storage errors (e.g. quota exceeded)
}
}, 3000);
}
_saveCommandHistory() {
try {
localStorage.setItem('webx11_command_history', JSON.stringify(this.history));
} catch (e) {
// Silently ignore storage errors
}
}
_trimOutput() {
const container = this.outputContainer;
while (container.children.length > this.MAX_OUTPUT_LINES) {
container.removeChild(container.firstChild);
}
}
markDirty() {
this._dirty = true;
}
_initAutoSave() {
if (this._autoSaveTimer) return;
this._autoSaveTimer = setInterval(() => {
// Auto-trim on memory pressure
try {
if (performance.memory && performance.memory.usedJSHeapSize > 80 * 1048576) {
const oldMax = this.MAX_OUTPUT_LINES;
this.MAX_OUTPUT_LINES = performance.memory.usedJSHeapSize > 120 * 1048576 ? 50 : 100;
this._trimOutput();
this.MAX_OUTPUT_LINES = oldMax;
this.print('๐งน Terminal trimmed (memory: ' + (performance.memory.usedJSHeapSize / 1048576).toFixed(0) + ' MB)', 'text-muted');
}
} catch (e) {}
if (!this._dirty) return;
this._autoSaveSession();
this._dirty = false;
}, 180000);
}
_autoSaveSession() {
try {
this.fs.mkdirRecursive('_sessions');
// Clone fsRoot and strip _sessions to prevent recursive nesting
var rootClone = JSON.parse(JSON.stringify(this.fs.root));
if (rootClone && rootClone.content && rootClone.content['_sessions']) {
delete rootClone.content['_sessions'];
}
const snapshot = {
type: 'session',
savedAt: new Date().toISOString(),
fsRoot: rootClone,
currentPath: this.fs.currentPath,
version: 1,
terminalLines: this._historyBuffer.slice(-50)
};
this.fs.writeFile('_sessions/_auto.json', JSON.stringify(snapshot, null, 2));
// Also persist AI context concurrently
try {
const ctx = localStorage.getItem('webx11_ai_context');
if (ctx) this.fs.writeFile('_sessions/_auto-context.json', ctx);
} catch (e) {}
} catch (e) {
// Silently handle auto-save failures
}
}
_autoRestoreSession() {
try {
this.fs.mkdirRecursive('_sessions');
const autoSession = this.fs.readFile('_sessions/_auto.json');
if (autoSession) {
const data = JSON.parse(autoSession);
if (data && data.fsRoot) {
this.fs.root = data.fsRoot;
this.fs.currentPath = data.currentPath || [];
this.fs.saveToStorage();
this.print('โป Auto-restored session from ' + (data.savedAt ? new Date(data.savedAt).toLocaleTimeString() : 'recent'), 'success');
// Defer AI context restoration (AI module loads after terminal init)
try {
const aiCtx = this.fs.readFile('_sessions/_auto-context.json');
if (aiCtx) {
localStorage.setItem('webx11_ai_context', aiCtx);
}
} catch (e) {}
}
}
} catch (e) {
// Silent on first boot โ no auto-session yet
}
}
scrollToBottom() {
this.outputContainer.scrollTop = this.outputContainer.scrollHeight;
}
showAutocomplete() {
const input = this.inputField.value;
const trimmed = input.trim();
const commands = this.moduleManager.getAllCommands();
this.suggestions = [];
this.selectedSuggestion = -1;
this._isCdAutocomplete = false;
if (trimmed.length === 0) {
this.hideAutocomplete();
return;
}
const parts = input.split(' ');
const cmdPart = parts[0].toLowerCase();
const lowerInput = input.toLowerCase();
// Commands that take file/dir arguments for autocomplete
const fileArgCommands = ['cat', 'preview', 'rm', 'rmdir', 'edit', 'view'];
const dirArgCommands = ['ls', 'cd'];
// Session commands that autocomplete from _sessions/*.json
const sessionArgCommands = ['session-load', 'session-delete', 'session-export', 'session-save'];
// If we have more than one word, we're past the command name โ autocomplete file/dir/session names
if (parts.length > 1 && parts[1].length > 0) {
const partial = parts.slice(1).join(' ');
const partialTrimmed = partial.trim();
if (partialTrimmed === '..') {
this.hideAutocomplete();
return;
}
// Determine if we should show session names, dirs only, or both
if (sessionArgCommands.includes(cmdPart)) {
this.fs.mkdirRecursive('_sessions');
const all = this.fs.ls('_sessions');
this.suggestions = all
.filter(e => e.type === 'file' && e.name.endsWith('.json'))
.map(e => e.name.replace('.json', ''))
.filter(n => n.startsWith(partialTrimmed));
} else if (cmdPart === 'cd' || cmdPart === 'rmdir' || dirArgCommands.includes(cmdPart)) {
// Dir-only commands
const entries = this.fs.ls().filter(f => f.type === 'dir');
this.suggestions = entries
.filter(e => e.name.startsWith(partialTrimmed))
.map(e => e.name + '/');
} else if (fileArgCommands.includes(cmdPart)) {
const all = this.fs.ls();
this.suggestions = all
.filter(e => e.name.startsWith(partialTrimmed))
.map(e => e.type === 'dir' ? e.name + '/' : e.name);
} else {
this.hideAutocomplete();
return;
}
this._autocompletePrefix = cmdPart + ' ';
if (this.suggestions.length > 0) {
this._isCdAutocomplete = (cmdPart === 'cd' || cmdPart === 'rmdir');
this.renderAutocomplete();
} else {
this.hideAutocomplete();
}
return;
}
// Just typed a space after command โ show all matching entries
if (parts.length > 1) {
if (sessionArgCommands.includes(cmdPart)) {
this.fs.mkdirRecursive('_sessions');
const all = this.fs.ls('_sessions');
this.suggestions = all
.filter(e => e.type === 'file' && e.name.endsWith('.json'))
.map(e => e.name.replace('.json', ''));
} else if (cmdPart === 'cd' || cmdPart === 'rmdir' || dirArgCommands.includes(cmdPart)) {
const dirs = this.fs.ls().filter(f => f.type === 'dir');
this.suggestions = dirs.map(d => d.name + '/');
} else if (fileArgCommands.includes(cmdPart)) {
const all = this.fs.ls();
this.suggestions = all.map(e => e.type === 'dir' ? e.name + '/' : e.name);
} else {
this.hideAutocomplete();
return;
}
this._autocompletePrefix = cmdPart + ' ';
if (this.suggestions.length > 0) {
this._isCdAutocomplete = (cmdPart === 'cd' || cmdPart === 'rmdir');
this.renderAutocomplete();
} else {
this.hideAutocomplete();
}
return;
}
// Directory autocomplete for bare 'cd ' (no partial yet) โ should be unreachable due to above block
if (lowerInput.startsWith('cd ')) {
const dirs = this.fs.ls().filter(f => f.type === 'dir');
this.suggestions = dirs.map(d => d.name + '/');
if (this.suggestions.length > 0) {
this._isCdAutocomplete = true;
this.renderAutocomplete();
} else {
this.hideAutocomplete();
}
return;
}
// Command name autocomplete (first word only) โ also clears any stale prefix
this._autocompletePrefix = '';
for (const cmd in commands) {
if (cmd.startsWith(cmdPart) && cmd !== cmdPart) {
this.suggestions.push(cmd);
}
}
if (this.suggestions.length > 0) {
this.renderAutocomplete();
} else {
this.hideAutocomplete();
}
}
renderAutocomplete() {
this.dropdown.innerHTML = '';
this.suggestions.forEach((item, index) => {
const div = document.createElement('div');
div.className = `autocomplete-item ${index === this.selectedSuggestion ? 'selected' : ''}`;
div.innerHTML = this._isCdAutocomplete
? `${item}`
: `${item}`;
div.onclick = () => {
if (this._autocompletePrefix) {
this.inputField.value = this._autocompletePrefix + item;
} else if (this._isCdAutocomplete) {
this.inputField.value = 'cd ' + item;
} else {
this.inputField.value = item + ' ';
}
this.hideAutocomplete();
this.inputField.focus();
};
this.dropdown.appendChild(div);
});
this.dropdown.style.display = 'block';
}
updateAutocompleteDisplay() {
const items = this.dropdown.querySelectorAll('.autocomplete-item');
items.forEach((item, index) => {
item.classList.toggle('selected', index === this.selectedSuggestion);
});
if (this.selectedSuggestion >= 0 && this.suggestions[this.selectedSuggestion]) {
if (this._autocompletePrefix) {
this.inputField.value = this._autocompletePrefix + this.suggestions[this.selectedSuggestion];
} else if (this._isCdAutocomplete) {
this.inputField.value = 'cd ' + this.suggestions[this.selectedSuggestion];
} else {
this.inputField.value = this.suggestions[this.selectedSuggestion] + ' ';
}
}
}
hideAutocomplete() {
this.dropdown.style.display = 'none';
this.suggestions = [];
this.selectedSuggestion = -1;
this._isCdAutocomplete = false;
this._autocompletePrefix = '';
}
loadBuiltInModules() {
this._safeRegister('SystemModule', 'system');
this._safeRegister('FileSystemModule', 'filesystem');
this._safeRegister('ModuleManagerModule', 'module manager');
this._safeRegister('OllamaModule', 'ollama');
this._safeRegister('SearXNGModule', 'searxng search');
this._safeRegister('ConfigManagerModule', 'config');
this._safeRegister('WebX11AIAssistantModule', 'AI assistant');
this._safeRegister('WebFetcherModule', 'web fetcher');
this._safeRegister('WebXAIAliasModule', 'AI alias');
this._safeRegister('SkillsModule', 'skills');
this._safeRegister('SecretVault', 'secret vault');
this.reloadPersistedModules();
this._safeRegister('ChatStudioModule', 'chat studio');
}
_safeRegister(className, label) {
let cls;
try {
cls = eval(className);
} catch (e) {
console.log(`[SafeRegister] Skipped ${label}: class not loaded (skipped in boot selection)`);
return;
}
if (typeof cls === 'undefined') {
console.log(`[SafeRegister] Skipped ${label}: class not defined`);
return;
}
try { this.moduleManager.registerModule(new cls()); }
catch (e) { console.warn(`[SafeRegister] Skipped ${label}: ${e.message}`); }
}
async _loadExtModules(filterKeys) {
const autoLoadKeys = filterKeys || ['calc', 'voice', 'cron', 'theme', 'wpm', 'mindmap', 'webdav', 'skill-pack-core', 'editor', 'diff', 'alias', 'finder'];
try {
const res = await fetch('packages.json');
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const index = await res.json();
for (const key of autoLoadKeys) {
const pkg = index.packages[key];
if (pkg) {
await this.moduleManager.loadModuleFromURL(pkg.url);
}
}
} catch (err) {
this.print(`Failed to auto-load ext modules: ${err.message}`, 'error');
}
}
async reloadPersistedModules() {
const registry = this.moduleManager.moduleRegistry;
const skipList = ['calc', 'voice', 'cron', 'theme', 'wpm', 'mindmap', 'webdav', 'skill-pack-core', 'editor', 'diff', 'alias', 'finder'];
for (const mod of registry) {
if (mod.enabled && mod.url) {
// Skip ext modules โ they load via _loadExtModules
const name = (mod.url.match(/(\w+)\.js/) || [])[1];
if (name && skipList.includes(name)) continue;
await this.moduleManager.loadModuleFromURL(mod.url);
}
}
}
async executeCommand(rawInput) {
if (!rawInput.trim()) return;
this.markDirty();
this.print(`webx11@module:~$ ${rawInput}`, 'command-prompt');
const pipeParts = this.splitPipe(rawInput.trim());
if (pipeParts.length > 1) {
await this.executePipeChain(pipeParts);
return;
}
const parts = rawInput.trim().split(/\s+/);
const cmd = parts[0].toLowerCase();
const args = parts.slice(1);
if (cmd === 'startup-modules') {
this.openStartupModules();
return;
}
if (cmd === 'font-size' || cmd === 'setfont') {
this.setFontSize(args[0]);
return;
}
if (cmd === 'load-local' || cmd === 'loadlocal') {
this.loadLocalModule(args[0]);
return;
}
if (cmd === 'smart-prompt') {
const val = args[0] && args[0] === 'off' ? 'off' : 'on';
localStorage.setItem('webx11_smart_prompt', val);
this.print('Smart prompt ' + val, 'success');
this.updatePrompt();
return;
}
if (cmd === 'mem') {
this.printMemoryStats();
return;
}
if (cmd === 'clear') {
this.outputContainer.innerHTML = '';
this._historyBuffer = [];
localStorage.removeItem('webx11_terminal_output');
return;
}
let executed = false;
for (const modName in this.moduleManager.modules) {
const mod = this.moduleManager.modules[modName];
if (mod.commands && mod.commands[cmd]) {
try {
await mod.commands[cmd](args);
executed = true;
} catch (err) {
this.print(`Error in ${modName}.${cmd}: ${err.message}`, 'error');
}
break;
}
}
if (!executed) {
// Route unknown commands to the AI agent
try {
const aiMod = this.moduleManager.modules['WebX11AIAssistantModule'];
if (aiMod) {
this.print('โ', 'info');
await aiMod.chat([rawInput.trim()]);
return;
}
} catch (e) {
this.print(`Agent error: ${e.message}`, 'error');
return;
}
}
}
splitPipe(input) {
const parts = [];
let current = '';
let inQuote = false;
for (const ch of input) {
if (ch === '"' || ch === "'") { inQuote = !inQuote; current += ch; }
else if (ch === '|' && !inQuote) { parts.push(current.trim()); current = ''; }
else { current += ch; }
}
parts.push(current.trim());
return parts;
}
async executePipeChain(pipeParts) {
let pipeInput = null;
for (let i = 0; i < pipeParts.length; i++) {
const cmdText = pipeParts[i];
const parts = cmdText.split(/\s+/);
const cmd = parts[0].toLowerCase();
const args = parts.slice(1);
this._pipeInput = pipeInput;
if (i < pipeParts.length - 1) {
pipeInput = await this.capturePipeOutput(cmd, args);
} else {
await this.dispatchCommand(cmd, args);
}
}
this._pipeInput = null;
}
async capturePipeOutput(cmd, args) {
const originalPrint = this.print.bind(this);
let output = '';
this.print = (text, type) => { output += text + '\n'; };
await this.dispatchCommand(cmd, args);
this.print = originalPrint;
const trimmed = output.trim();
if (trimmed.length > this._pipeBufferSize) {
return trimmed.slice(0, this._pipeBufferSize);
}
return trimmed;
}
async dispatchCommand(cmd, args) {
if (cmd === 'clear') { this.outputContainer.innerHTML = ''; this._historyBuffer = []; localStorage.removeItem('webx11_terminal_output'); return; }
for (const modName in this.moduleManager.modules) {
const mod = this.moduleManager.modules[modName];
if (mod.commands && mod.commands[cmd]) {
try { await mod.commands[cmd](args); return; }
catch (err) { this.print(`Error in ${modName}.${cmd}: ${err.message}`, 'error'); return; }
}
}
// Route unknown commands to the AI agent
try {
const aiMod = this.moduleManager.modules['WebX11AIAssistantModule'];
if (aiMod) {
this.print('โ Routing to AI assistant...', 'info');
await aiMod.chat([cmd + ' ' + args.join(' ')]);
return;
}
} catch (e) {
this.print(`Agent error: ${e.message}`, 'error');
}
this.print(`Command not found: ${cmd}`, 'error');
}
openFileBrowser() {
const modal = document.getElementById('file-browser');
const content = document.getElementById('fb-content');
content.innerHTML = '';
const files = this.fs.ls();
if (files.length === 0) {
content.innerHTML = 'Directory Empty
';
} else {
files.forEach(f => {
const div = document.createElement('div');
div.className = `file-item ${f.type === 'dir' ? 'dir' : ''}`;
div.innerHTML = `${f.type === 'dir' ? '๐' : '๐'} ${f.name} ${f.size}b`;
div.onclick = () => {
if (f.type === 'file') {
const content = this.fs.readFile(f.name);
this.print(`--- ${f.name} ---`);
this.print(content);
this.print('--- End ---');
this.closeFileBrowser();
}
};
content.appendChild(div);
});
}
modal.style.display = 'flex';
}
closeFileBrowser() {
document.getElementById('file-browser').style.display = 'none';
this.inputField.focus();
}
openModuleManager() {
const modal = document.getElementById('module-manager');
this.renderModuleList();
modal.style.display = 'flex';
}
closeModuleManager() {
document.getElementById('module-manager').style.display = 'none';
this.inputField.focus();
}
renderModuleList() {
const list = document.getElementById('module-list');
list.innerHTML = '';
const modules = this.moduleManager.getModuleList();
if (modules.length === 0) {
list.innerHTML = 'No modules loaded
';
return;
}
modules.forEach(mod => {
const div = document.createElement('div');
div.className = `module-item ${mod.loaded ? '' : 'disabled'}`;
const badge = mod.url ? '' : 'Built-in';
div.innerHTML = `
${mod.name}${badge}
${mod.url || 'Built-in'}
${mod.url ? `` : ''}
`;
list.appendChild(div);
});
}
async loadModuleFromURL() {
const url = document.getElementById('module-url-input').value.trim();
if (!url) { alert('Please enter a URL'); return; }
const success = await this.moduleManager.loadModuleFromURL(url);
if (success) {
document.getElementById('module-url-input').value = '';
this.renderModuleList();
}
}
removeModule(name) {
if (this.moduleManager.unregisterModule(name)) {
this.print(`Module '${name}' removed`, 'success');
this.renderModuleList();
}
}
clearAllModules() {
if (confirm('Remove all custom modules? Built-in modules will remain.')) {
this.moduleManager.clearAll();
this.renderModuleList();
this.print('All custom modules cleared', 'success');
}
}
openOllamaManager() {
const modal = document.getElementById('ollama-manager');
document.getElementById('ollama-endpoint').value = this.ollamaManager.endpoint;
this.updateOllamaStatus();
this.renderOllamaModels();
modal.style.display = 'flex';
}
closeOllamaManager() {
document.getElementById('ollama-manager').style.display = 'none';
this.inputField.focus();
}
openPreview(args) {
let path = '';
if (typeof args === 'string') path = args.trim();
else if (Array.isArray(args)) path = args.join(' ').trim();
if (!path) {
this.print('Usage: preview ', 'warning');
return;
}
// Remove leading slash for VFS paths
const vfsPath = path.replace(/^\/*/, '');
let content = this.fs.readFile(vfsPath);
if (content === null) content = this.fs.fsRead('/' + vfsPath);
if (content === null) {
this.print(`File not found: ${path}`, 'error');
return;
}
// Use srcdoc instead of blob URL โ works reliably on mobile browsers
const iframe = document.getElementById('preview-iframe');
iframe.srcdoc = content;
document.getElementById('preview-title').textContent = `๐ ${path}`;
document.getElementById('preview-modal').style.display = 'flex';
}
closePreview() {
document.getElementById('preview-modal').style.display = 'none';
const iframe = document.getElementById('preview-iframe');
iframe.srcdoc = '';
iframe.src = 'about:blank';
this.inputField.focus();
}
openSkillsManager() {
const modal = document.getElementById('skills-manager');
// Ensure /skills/ directory exists
const skillsMod = this.moduleManager.modules['SkillsModule'];
if (skillsMod && skillsMod.ensureSkillsDir) skillsMod.ensureSkillsDir();
this.refreshSkillsList();
modal.style.display = 'flex';
}
closeSkillsManager() {
document.getElementById('skills-manager').style.display = 'none';
this.inputField.focus();
}
openConfigManager() {
document.getElementById('config-manager').style.display = 'flex';
this.renderConfigList();
}
closeConfigManager() {
document.getElementById('config-manager').style.display = 'none';
this.inputField.focus();
}
renderConfigList() {
const list = document.getElementById('config-list');
if (!list) return;
const prefix = 'webx11_config_';
const entries = [];
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key && key.startsWith(prefix)) {
entries.push({ key: key.replace(prefix, ''), value: localStorage.getItem(key) });
}
}
if (entries.length === 0) {
list.innerHTML = 'No configuration saved.
';
return;
}
list.innerHTML = entries.map(e => {
const displayVal = e.key.toLowerCase().includes('password') || e.key.toLowerCase().includes('token') || e.key.toLowerCase().includes('secret')
? '****' : e.value;
return `
${e.key} ${displayVal}
`;
}).join('');
}
addConfigFromUI() {
const key = document.getElementById('config-key-input').value.trim();
const value = document.getElementById('config-value-input').value.trim();
if (!key || !value) {
this.print('Enter both key and value', 'warning');
return;
}
localStorage.setItem('webx11_config_' + key, value);
document.getElementById('config-key-input').value = '';
document.getElementById('config-value-input').value = '';
this.print(`Config saved: ${key} = ${value}`, 'success');
this.renderConfigList();
}
deleteConfigFromUI(key) {
localStorage.removeItem('webx11_config_' + key);
this.print(`Config '${key}' deleted`, 'success');
this.renderConfigList();
}
exportConfigFromUI() {
const mod = this.moduleManager.modules['ConfigManagerModule'];
if (mod && mod.commands && mod.commands['config-export']) {
mod.commands['config-export']();
}
}
resetConfigFromUI() {
const mod = this.moduleManager.modules['ConfigManagerModule'];
if (mod && mod.commands && mod.commands['config-reset']) {
mod.commands['config-reset']();
this.renderConfigList();
}
}
refreshSkillsList() {
const list = document.getElementById('skills-list');
list.innerHTML = '';
const fs = this.fs;
fs.mkdirRecursive('/skills');
const index = fs.fsReadJSON('/skills/index.json') || {};
const names = Object.keys(index);
if (names.length === 0) {
list.innerHTML = 'No skills yet. Create one below!
';
return;
}
names.forEach(name => {
const s = index[name];
const tags = (s.tags || []).join(', ');
const div = document.createElement('div');
div.className = 'module-item';
div.innerHTML = `
${name}
${s.description || ''}
${s.category || 'general'}${tags ? ' ยท ' + tags : ''}
`;
list.appendChild(div);
});
}
viewSkillUI(name) {
const fs = this.fs;
const content = fs.fsRead(`/skills/${name}.md`);
if (!content) { this.print(`Skill '${name}' not found`, 'error'); return; }
document.getElementById('skill-preview-title').textContent = name;
document.getElementById('skill-preview-content').textContent = content;
document.getElementById('skill-preview').style.display = 'block';
}
closeSkillPreview() {
document.getElementById('skill-preview').style.display = 'none';
}
deleteSkillUI(name) {
if (!confirm(`Delete skill "${name}"?`)) return;
const fs = this.fs;
fs.mkdirRecursive('/skills');
fs.fsWrite(`/skills/${name}.md`, '');
const index = fs.fsReadJSON('/skills/index.json') || {};
delete index[name];
fs.fsWriteJSON('/skills/index.json', index);
this.print(`Skill '${name}' deleted`, 'success');
this.refreshSkillsList();
}
createSkillFromUI() {
const name = document.getElementById('skill-name-input').value.trim().replace(/\s+/g, '-').toLowerCase();
const description = document.getElementById('skill-desc-input').value.trim() || 'No description';
const tags = document.getElementById('skill-tags-input').value.trim().split(',').map(t => t.trim()).filter(Boolean);
const contentInput = document.getElementById('skill-content-input').value.trim();
if (!name) { alert('Skill name is required'); return; }
const fs = this.fs;
fs.mkdirRecursive('/skills');
if (fs.fsRead(`/skills/${name}.md`)) {
alert(`Skill '${name}' already exists. Delete it first or use skill-patch command.`);
return;
}
let content = contentInput || `---\nname: ${name}\ndescription: ${description}\ntags: [${tags.join(', ')}]\ncategory: general\n---\n## Steps\n1. (to be filled)\n`;
if (!content.startsWith('---')) {
content = `---\nname: ${name}\ndescription: ${description}\ntags: [${tags.join(', ')}]\ncategory: general\n---\n${content}`;
}
fs.fsWrite(`/skills/${name}.md`, content);
const index = fs.fsReadJSON('/skills/index.json') || {};
index[name] = { name, description, category: 'general', tags };
fs.fsWriteJSON('/skills/index.json', index);
this.print(`Skill '${name}' created`, 'success');
document.getElementById('skill-name-input').value = '';
document.getElementById('skill-desc-input').value = '';
document.getElementById('skill-tags-input').value = '';
document.getElementById('skill-content-input').value = '';
this.refreshSkillsList();
}
updateOllamaStatus() {
const statusDiv = document.getElementById('ollama-status');
const statusText = document.getElementById('ollama-status-text');
if (this.ollamaManager.connected) {
statusDiv.className = 'ollama-status connected';
statusText.textContent = `Connected (${this.ollamaManager.models.length} models)`;
} else {
statusDiv.className = 'ollama-status disconnected';
statusText.textContent = 'Disconnected';
}
}
renderOllamaModels() {
const container = document.getElementById('ollama-models');
const select = document.getElementById('ollama-current-model');
container.innerHTML = '';
select.innerHTML = '';
const models = this.ollamaManager.getModels();
if (models.length === 0) {
container.innerHTML = 'Connect to see models
';
return;
}
models.forEach(model => {
const div = document.createElement('div');
div.className = 'model-item';
div.innerHTML = `${model.name}${model.size ? (model.size / 1e9).toFixed(1) + 'GB' : ''}`;
div.onclick = () => {
this.ollamaManager.setModel(model.name);
select.value = model.name;
};
container.appendChild(div);
const option = document.createElement('option');
option.value = model.name;
option.textContent = model.name;
if (model.name === this.ollamaManager.currentModel) { option.selected = true; }
select.appendChild(option);
});
}
async connectOllama() {
const endpoint = document.getElementById('ollama-endpoint').value.trim();
this.ollamaManager.setEndpoint(endpoint);
const success = await this.ollamaManager.connect();
this.updateOllamaStatus();
if (success) { this.renderOllamaModels(); }
}
setOllamaModel() {
const select = document.getElementById('ollama-current-model');
this.ollamaManager.setModel(select.value);
}
openStartupModules() {
document.getElementById('startup-modules').style.display = 'flex';
this.renderStartupModules();
}
closeStartupModules() {
document.getElementById('startup-modules').style.display = 'none';
}
renderStartupModules() {
const body = document.getElementById('startup-modules-body');
const selected = (() => {
try {
const s = localStorage.getItem('webx11_startup_modules');
return s ? JSON.parse(s) : null;
} catch (e) { return null; }
})();
const isSel = (name) => !selected || selected.includes(name);
body.innerHTML = '';
const foundation = ['virtual-fs.js','module-manager.js','ollama-manager.js','terminal.js'];
const builtin = [
'module-system.js','module-fs.js','module-modmgr.js','module-ollama.js',
'module-my.js','module-searxng.js','module-servermon.js','module-ollamamodels.js',
'module-config.js','module-simplegui.js','module-io.js','module-dynamicprompts.js',
'module-aiassist.js','module-aiasm.js','module-smartctx.js','module-duckduckgo.js',
'module-fetcher.js','module-skills.js','module-chatstudio.js'
];
const extFiles = [
'calc.js','voice.js','cron.js','theme.js','wpm.js','mindmap.js','skill-pack-core.js'
];
const addSection = (title) => {
const h = document.createElement('div');
h.className = 'mod-section-header';
h.textContent = title;
body.appendChild(h);
};
const addCard = (name, badge, isDisabled) => {
const card = document.createElement('div');
card.className = 'mod-card' + (isDisabled ? ' disabled' : '');
const label = document.createElement('label');
const cb = document.createElement('input');
cb.type = 'checkbox';
cb.checked = isDisabled || isSel(name);
cb.disabled = isDisabled;
cb.dataset.name = name;
const nameSpan = document.createElement('span');
nameSpan.className = 'mod-name';
nameSpan.textContent = name;
const badgeSpan = document.createElement('span');
badgeSpan.className = 'mod-badge' + (badge === 'foundation' ? ' foundation' : badge === 'ext' ? ' ext' : '');
badgeSpan.textContent = badge;
label.appendChild(cb);
label.appendChild(nameSpan);
card.appendChild(label);
card.appendChild(badgeSpan);
body.appendChild(card);
};
addSection('Foundation (always loaded)');
foundation.forEach(n => addCard(n, 'foundation', true));
addSection('Built-in modules');
builtin.forEach(n => addCard(n, 'built-in', false));
addSection('Extensions');
extFiles.forEach(n => addCard(n, 'ext', false));
}
saveStartupModules() {
const cbs = document.querySelectorAll('#startup-modules-body input[type="checkbox"]:not(:disabled)');
const selected = [];
cbs.forEach(cb => {
if (cb.checked) selected.push(cb.dataset.name);
});
try {
localStorage.setItem('webx11_startup_modules', JSON.stringify(selected));
} catch (e) {
this.print('Failed to save startup modules', 'error');
return;
}
this.print('Startup modules saved, reloading...', 'success');
setTimeout(() => location.reload(), 500);
}
openCodeEditor(name, url, code) {
const modal = document.getElementById('code-editor');
const title = document.getElementById('code-editor-title');
const info = document.getElementById('code-editor-info');
const ta = document.getElementById('code-editor-textarea');
if (!modal || !ta) return;
this._editingModule = { name: name, url: url };
title.textContent = '๐ Edit: ' + name;
info.textContent = 'Module: ' + name + (url ? ' | Source: ' + url : '');
ta.value = code;
modal.style.display = 'flex';
}
closeCodeEditor() {
document.getElementById('code-editor').style.display = 'none';
this._editingModule = null;
}
async loadLocalModule(path) {
if (!path) {
this.print('Usage: load-local ', 'warning');
this.print('Loads a module from VFS (e.g. "load-local modules/ext/mindmap.js") or from URL', 'info');
return;
}
let code = this.fs.readFile(path);
if (code === null) {
try {
const resp = await fetch(path);
if (!resp.ok) throw new Error('HTTP ' + resp.status);
code = await resp.text();
} catch (e) {
this.print('load-local: file not found in VFS or at URL: ' + path, 'error');
return;
}
}
try {
const el = document.createElement('script');
el.textContent = code;
el.onerror = () => this.print('load-local: failed to execute ' + path, 'error');
document.body.appendChild(el);
this.print('Module loaded from ' + path, 'success');
} catch (e) {
this.print('load-local: error: ' + e.message, 'error');
}
}
async saveCodeEditor() {
const ta = document.getElementById('code-editor-textarea');
const mod = this._editingModule;
if (!mod || !ta) return;
const code = ta.value;
this.fs.writeFile('_mods/' + mod.name + '.js', code);
if (!this.fs.readFile('_mods/' + mod.name + '.orig.js')) {
this.fs.writeFile('_mods/' + mod.name + '.orig.js', 'placeholder');
}
this.fs.writeFile('_mods/' + mod.name + '.meta.json', JSON.stringify({
originalUrl: mod.url || '',
modifiedAt: new Date().toISOString(), editCount: 1
}));
try {
await this.moduleManager.loadModuleFromCode(code, 'blob://_mods/' + mod.name + '.js');
this.print('โ
Module loaded live from _mods/' + mod.name + '.js', 'success');
} catch (e) {
this.print('โ ๏ธ Auto-test failed: ' + e.message + ' โ edits saved but module not loaded', 'error');
}
this.closeCodeEditor();
}
_restoreFontSize() {
try {
const saved = localStorage.getItem('webx11_font_size');
if (saved) {
const s = parseInt(saved);
if (s >= 8 && s <= 32) document.documentElement.style.fontSize = s + 'px';
}
} catch (e) {}
}
log(level, source, message) {
const d = new Date();
const ts = d.toISOString().split('T')[1].split('.')[0];
const entry = `[${ts}][${level}][${source}] ${message}`;
console.log(entry);
// Write to VFS log
try {
this.fs.mkdirRecursive('/logs');
let current = this.fs.readFile('/logs/current.log') || '';
current += entry + '\n';
const lines = current.split('\n').filter(l => l);
if (lines.length > 200) {
this.fs.writeFile('/logs/old.log', lines.slice(0, -100).join('\n'));
this.fs.writeFile('/logs/current.log', lines.slice(-100).join('\n'));
} else {
this.fs.writeFile('/logs/current.log', current);
}
} catch (e) {}
}
setAiBusy(busy) {
this._aiBusy = !!busy;
if (this._aiBusyInterval) {
clearInterval(this._aiBusyInterval);
this._aiBusyInterval = null;
}
const cancelBtn = document.getElementById('ai-cancel-btn');
if (busy) {
if (cancelBtn) cancelBtn.style.display = 'inline-block';
this._aiBusyStart = Date.now();
const updateTimer = () => {
let elapsed = Math.round((Date.now() - this._aiBusyStart) / 1000);
let sec = elapsed % 60;
let min = Math.floor(elapsed / 60);
this.statusBar.textContent = '๐ค AI Thinking... โฑ ' + (min > 0 ? min + 'm ' : '') + sec + 's';
this.statusBar.className = 'loading';
};
updateTimer();
this._aiBusyInterval = setInterval(updateTimer, 1000);
} else {
if (cancelBtn) cancelBtn.style.display = 'none';
this.statusBar.textContent = 'Ready';
this.statusBar.className = '';
this._aiBusyStart = 0;
}
}
printMemoryStats() {
const pm = typeof performance !== 'undefined' && performance.memory;
let heapInfo = '';
if (pm) {
const used = (pm.usedJSHeapSize / 1048576).toFixed(1);
const total = (pm.totalJSHeapSize / 1048576).toFixed(1);
const limit = (pm.jsHeapSizeLimit / 1048576).toFixed(1);
heapInfo = `JS heap: ${used}MB / ${total}MB (limit ${limit}MB)`;
} else {
try {
const lsSize = (new Blob([JSON.stringify(localStorage)]).size / 1024).toFixed(1);
heapInfo = `localStorage: ${lsSize}KB`;
} catch (e) {
heapInfo = 'Memory API not available on this browser';
}
}
const output = [
`๐ Memory Stats`,
` ${heapInfo}`,
` Terminal lines in DOM: ${this.outputContainer.children.length}`,
` History buffer: ${this._historyBuffer.length} entries`,
` MAX_OUTPUT_LINES: ${this.MAX_OUTPUT_LINES}`,
` Commands in history: ${this.history.length}`
].join('\n');
this.print(output, 'info');
}
playNotificationSound() {
if (localStorage.getItem('webx11_config_ai_sound') === 'off') return;
try {
const ctx = new (window.AudioContext || window.webkitAudioContext)();
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.connect(gain);
gain.connect(ctx.destination);
osc.frequency.value = 880;
osc.type = 'sine';
gain.gain.setValueAtTime(0.15, ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.3);
osc.start(ctx.currentTime);
osc.stop(ctx.currentTime + 0.3);
} catch (e) {}
}
toggleMicVisibility() {
const voiceMod = this.moduleManager.modules['VoiceModule'];
if (!voiceMod) {
this.print('Voice module not loaded', 'warning');
return;
}
if (voiceMod.listening) {
voiceMod.stopListening();
} else {
voiceMod.handle([]);
}
}
setFontSize(size) {
const s = parseInt(size);
if (isNaN(s) || s < 8 || s > 32) {
this.print('Usage: font-size <8-32>', 'warning');
return;
}
document.documentElement.style.fontSize = s + 'px';
try { localStorage.setItem('webx11_font_size', String(s)); } catch (e) {}
this.print('Font size set to ' + s + 'px', 'success');
}
}