// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ // DONAU2SPACE // DEV ENTITY — Terminal Engine // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ import { $, esc, sleep, bus, store, pick } from './utils.js'; import { keyClick } from './audio.js'; // Helper: create a with class and text function span(cls, text) { const s = document.createElement('span'); if (cls) s.className = cls; s.textContent = text; return s; } export class Terminal { constructor(screenEl, inputEl) { this.screen = screenEl; this.input = inputEl; this.commands = {}; this.history = store.get('history', []); this.hIndex = this.history.length; this.locked = false; this.commandCount = 0; this.unknownAttempts = 0; this._bindInput(); } // ── Output methods ─────────────────────────── print(text, cls = 'line') { const div = document.createElement('div'); div.className = cls; div.textContent = text; this.screen.appendChild(div); this._scroll(); } // Print a line containing multiple styled spans. // segments: array of { text, cls } or plain strings printStyled(segments, lineCls = 'line') { const div = document.createElement('div'); div.className = lineCls; for (const seg of segments) { if (typeof seg === 'string') { div.appendChild(document.createTextNode(seg)); } else { div.appendChild(span(seg.cls || '', seg.text)); } } this.screen.appendChild(div); this._scroll(); } hr() { const div = document.createElement('div'); div.className = 'line c-muted dim'; div.textContent = '\u2500'.repeat(58); this.screen.appendChild(div); this._scroll(); } blank() { this.print(''); } _buildPS1() { const frag = document.createDocumentFragment(); frag.appendChild(span('c-green', 'mika')); frag.appendChild(span('c-muted', '@')); frag.appendChild(span('c-muted', 'dev')); frag.appendChild(span('c-muted', ':')); frag.appendChild(span('c-green', '~')); frag.appendChild(span('c-muted', '$ ')); return frag; } clear() { this.screen.replaceChildren(); } // ── Typing effect ──────────────────────────── async typing(lines, speed = 12, cls = 'line') { for (const line of lines) { const div = document.createElement('div'); div.className = cls; this.screen.appendChild(div); let buf = ''; for (const ch of line) { buf += ch; div.textContent = buf; this._scroll(); await sleep(speed + (Math.random() * 10 | 0)); } } } async typingSlow(text, speed = 35, cls = 'line c-muted') { const div = document.createElement('div'); div.className = cls; this.screen.appendChild(div); let buf = ''; for (const ch of text) { buf += ch; div.textContent = buf; this._scroll(); await sleep(speed + (Math.random() * 20 | 0)); } } // ── Command registration ───────────────────── register(name, fn, meta = {}) { this.commands[name] = { fn, meta }; } alias(newName, existingName) { if (this.commands[existingName]) { this.commands[newName] = this.commands[existingName]; } } listCommands() { return Object.keys(this.commands).sort(); } // ── Command execution ──────────────────────── async run(raw) { const trimmed = (raw || '').trim(); if (!trimmed) return; // Save history this.history.push(trimmed); if (this.history.length > 200) this.history = this.history.slice(-200); store.set('history', this.history); this.hIndex = this.history.length; // Print command line with PS1 const cmdLine = document.createElement('div'); cmdLine.className = 'line'; cmdLine.appendChild(this._buildPS1()); cmdLine.appendChild(span('dim', trimmed)); this.screen.appendChild(cmdLine); this._scroll(); // Parse const parts = this._parseArgs(trimmed); const name = (parts[0] || '').toLowerCase(); const args = parts.slice(1); this.commandCount++; bus.emit('command', { name, args, raw: trimmed, count: this.commandCount }); // Route const entry = this.commands[name]; if (!entry) { this.unknownAttempts++; bus.emit('unknown-command', { name, count: this.unknownAttempts }); if (this.unknownAttempts <= 2) { this.printStyled([ { text: 'command not found: ', cls: 'c-red' }, { text: name, cls: 'c-muted' } ]); this.printStyled([ { text: 'try: ', cls: 'c-muted' }, { text: 'help', cls: 'c-blue' } ]); } else if (this.unknownAttempts <= 5) { this.print("I don't know that command.", 'line c-muted'); this.print("But I like that you tried.", 'line c-muted'); } else { const responses = [ "Still guessing? Respect.", "You're persistent. I'll give you that.", "That's not a command. But it could be.", "Unknown. Like most things here.", "Nope. But keep going.", ]; this.print(pick(responses), 'line c-muted'); } return; } try { this.locked = true; await entry.fn(args, this); this.locked = false; } catch (e) { this.locked = false; this.printStyled([ { text: 'error: ', cls: 'c-red' }, { text: String(e), cls: 'c-muted' } ]); } } // ── System message (no prompt) ─────────────── systemMessage(text, cls = 'c-muted') { const div = document.createElement('div'); div.className = `line ${cls} system-msg`; div.textContent = text; this.screen.appendChild(div); this._scroll(); } // ── Input binding ──────────────────────────── _bindInput() { this.input.addEventListener('keydown', (e) => { if (this.locked && e.key === 'Enter') { e.preventDefault(); return; } if (e.key === 'Enter') { const val = this.input.value; this.input.value = ''; this.run(val); return; } if (e.key === 'ArrowUp') { e.preventDefault(); if (this.history.length) { this.hIndex = Math.max(0, this.hIndex - 1); this.input.value = this.history[this.hIndex] || ''; setTimeout(() => this.input.setSelectionRange(this.input.value.length, this.input.value.length), 0); } return; } if (e.key === 'ArrowDown') { e.preventDefault(); if (this.history.length) { this.hIndex = Math.min(this.history.length, this.hIndex + 1); this.input.value = this.history[this.hIndex] || ''; setTimeout(() => this.input.setSelectionRange(this.input.value.length, this.input.value.length), 0); } return; } if (e.key === 'Tab') { e.preventDefault(); const v = this.input.value; const prefix = v.trim().split(/\s+/)[0] || ''; if (!prefix) return; const opts = this.listCommands().filter(c => c.startsWith(prefix.toLowerCase())); if (opts.length === 1) { this.input.value = opts[0] + (v.includes(' ') ? v.slice(v.indexOf(' ')) : ' '); } else if (opts.length > 1 && opts.length <= 12) { this.print(opts.join(' '), 'line c-muted'); } return; } // Subtle key click sound if (e.key.length === 1) keyClick(); }); // Focus management this.screen.addEventListener('click', () => this.input.focus()); } // ── Arg parsing ────────────────────────────── _parseArgs(raw) { const out = []; let cur = '', q = null; for (let i = 0; i < raw.length; i++) { const ch = raw[i]; if (q) { if (ch === q) { q = null; continue; } cur += ch; continue; } if (ch === '"' || ch === "'") { q = ch; continue; } if (/\s/.test(ch)) { if (cur) { out.push(cur); cur = ''; } continue; } cur += ch; } if (cur) out.push(cur); return out; } _scroll() { this.screen.scrollTop = this.screen.scrollHeight; } focus() { this.input.focus(); } }