Files
code/web/js/terminal.js
elffuss-bot 42a4a9a67c Stand-in import of elffuss/code
One parentless commit with only the files that the open seed tasks touch and the modules they import.
The full squashed import (SPEC 8.6) is a later step: this tree is not the cutoff tree.

T2T-StandIn-Source-Commit: 43f95b7333efe73aeaebf806023f17bffea27aba
2026-10-01 00:00:00 +00:00

141 lines
6.8 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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);
}