elffuss-code / js /terminal.js
KikoCis's picture
Elffuss Code — browser IDE with a local AI agent (WebGPU)
b30b7c5 verified
Raw
History Blame Contribute Delete
6.92 kB
// Terminal de Elffuss Code: xterm.js (VT100 real) + un readline propio que
// ejecuta contra shell.js (los ficheros REALES del proyecto). xterm no trae
// shell: nosotros gestionamos la línea (edición, historial, cursor) y delegamos
// la ejecución en shell.exec. La misma orden la puede lanzar la elfa vía el
// tool `terminal.run`, y su salida aparece aquí.
import * as shell from './shell.js';
let term = null, fit = null, ready = false;
let Terminal = null, FitAddon = null;
let line = '', pos = 0, histIdx = -1, busy = false;
const XT = '@xterm/xterm@5.5.0', FIT = '@xterm/addon-fit@0.10.0';
// IMPORTANTE: cargamos xterm como ESM, no UMD. Monaco trae un loader AMD
// (define/require) y la build UMD de xterm se registra como «define anónimo»
// en vez de exponer window.Terminal → «Can only have one anonymous define».
// El import ESM esquiva por completo el define de Monaco.
async function loadXterm() {
if (Terminal) return;
if (!document.getElementById('xterm-css')) {
const l = document.createElement('link');
l.id = 'xterm-css'; l.rel = 'stylesheet'; l.href = `https://cdn.jsdelivr.net/npm/${XT}/css/xterm.min.css`;
document.head.appendChild(l);
}
const [xt, ft] = await Promise.all([
import(`https://cdn.jsdelivr.net/npm/${XT}/+esm`),
import(`https://cdn.jsdelivr.net/npm/${FIT}/+esm`),
]);
Terminal = xt.Terminal || (xt.default && xt.default.Terminal);
FitAddon = ft.FitAddon || (ft.default && ft.default.FitAddon) || ft.default;
}
const prompt = () => `\x1b[36m${shell.cwdString()}\x1b[0m \x1b[35m❯\x1b[0m `;
const writePrompt = () => term.write('\r\n' + prompt());
// Redibuja la línea de entrada (prompt + buffer) y coloca el cursor en `pos`.
function redraw() {
term.write('\r\x1b[K' + prompt() + line);
const back = line.length - pos;
if (back > 0) term.write(`\x1b[${back}D`);
}
async function run(raw) {
const cmd = raw.trim();
if (!cmd) { writePrompt(); return; }
busy = true;
try {
const out = await shell.exec(cmd);
if (out === '\f') term.clear();
else if (out) term.write('\r\n' + out.replace(/\n/g, '\r\n'));
} catch (e) {
term.write('\r\n\x1b[31m' + String(e && e.message || e).replace(/\n/g, '\r\n') + '\x1b[0m');
}
busy = false;
writePrompt();
}
function onKey(data) {
if (busy) return;
switch (data) {
case '\r': // Enter
{ const cmd = line; line = ''; pos = 0; histIdx = -1; run(cmd); return; }
case '\x7f': // Retroceso
if (pos > 0) { line = line.slice(0, pos - 1) + line.slice(pos); pos--; redraw(); } return;
case '\x1b[3~': // Supr
if (pos < line.length) { line = line.slice(0, pos) + line.slice(pos + 1); redraw(); } return;
case '\x03': // Ctrl+C
term.write('^C'); line = ''; pos = 0; histIdx = -1; writePrompt(); return;
case '\x0c': // Ctrl+L
term.clear(); redraw(); return;
case '\x1b[A': case '\x1b[B': { // arriba / abajo: historial
const h = shell.getHistory(); if (!h.length) return;
if (histIdx === -1) histIdx = h.length;
histIdx = Math.max(0, Math.min(h.length, histIdx + (data === '\x1b[A' ? -1 : 1)));
line = h[histIdx] || ''; pos = line.length; redraw(); return;
}
case '\x1b[C': if (pos < line.length) { pos++; term.write('\x1b[C'); } return; // derecha
case '\x1b[D': if (pos > 0) { pos--; term.write('\x1b[D'); } return; // izquierda
case '\x1b[H': case '\x01': pos = 0; redraw(); return; // Inicio / Ctrl+A
case '\x1b[F': case '\x05': pos = line.length; redraw(); return; // Fin / Ctrl+E
case '\t': complete(); return; // Tab: completar rutas
}
if (data.length !== 1 || data.charCodeAt(0) < 32) return; // control/escape: ignorar
line = line.slice(0, pos) + data + line.slice(pos); pos += data.length; redraw();
}
// Completado de rutas simple: mira el último token contra el directorio actual.
async function complete() {
const frag = (line.slice(0, pos).split(/\s+/).pop()) || '';
const slash = frag.lastIndexOf('/');
const dirPart = slash >= 0 ? frag.slice(0, slash + 1) : '';
const base = slash >= 0 ? frag.slice(slash + 1) : frag;
let names;
try { names = await shell.listDir(dirPart); } catch { return; }
const matches = names.filter(n => n.name.startsWith(base));
if (matches.length === 1) {
const add = matches[0].name.slice(base.length) + (matches[0].kind === 'directory' ? '/' : ' ');
line = line.slice(0, pos) + add + line.slice(pos); pos += add.length; redraw();
} else if (matches.length > 1) {
term.write('\r\n' + matches.map(m => m.kind === 'directory' ? '\x1b[36m' + m.name + '/\x1b[0m' : m.name).join(' '));
writePrompt(); term.write(line); if (line.length - pos > 0) term.write(`\x1b[${line.length - pos}D`);
}
}
// Monta el terminal en `host` (una vez). Idempotente; refoca si ya existe.
export async function mount(host) {
if (ready) { setTimeout(() => { fit && fit.fit(); term && term.focus(); }, 0); return; }
await loadXterm();
term = new Terminal({
fontSize: 12.5, fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace',
cursorBlink: true, convertEol: false, scrollback: 2000,
theme: { background: '#0b0d12', foreground: '#e8ebf4', cursor: '#ff4d8d', selectionBackground: '#7c5cff55',
brightBlack: '#6b7280', red: '#ff6b8b', green: '#7ee787', yellow: '#ffd479', blue: '#7c5cff', magenta: '#ff4d8d', cyan: '#56d4dd' },
});
fit = new FitAddon();
term.loadAddon(fit);
term.open(host);
fit.fit();
host.addEventListener('mousedown', () => setTimeout(() => term.focus(), 0));
term.onData(onKey);
const cap = shell.capabilities();
term.write('\x1b[1;35mElffuss shell\x1b[0m \x1b[90m— ficheros reales del proyecto, en tu navegador.\x1b[0m');
const rtHint = cap.bridge ? '🔌 Bridge local conectado: node/npm/python REALES en tu máquina.'
: 'Escribe \x1b[0m\x1b[32mhelp\x1b[0m\x1b[90m. node/npm/python reales → conecta el Bridge local (⚙ Ajustes).';
term.write('\r\n\x1b[90m' + rtHint + '\x1b[0m');
writePrompt();
ready = true;
setTimeout(() => term.focus(), 0);
}
export function refit() { if (ready) setTimeout(() => fit && fit.fit(), 0); }
export function focus() { if (ready) term.focus(); }
export function isReady() { return ready; }
export function clearScreen() { if (ready) { term.clear(); line = ''; pos = 0; term.write('\r\x1b[K' + prompt()); term.focus(); } }
// Eco de una orden lanzada por la elfa (para que el usuario la vea en el terminal).
export function echoAgent(cmd, out) {
if (!ready) return;
term.write('\r\x1b[K' + prompt() + '\x1b[90m' + cmd + ' (elfa)\x1b[0m');
if (out) term.write('\r\n' + shell.stripAnsi(out).replace(/\n/g, '\r\n'));
writePrompt(); term.write(line);
}