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
This commit is contained in:
140
web/js/terminal.js
Normal file
140
web/js/terminal.js
Normal file
@@ -0,0 +1,140 @@
|
||||
// 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);
|
||||
}
|
||||
Reference in New Issue
Block a user