// Elffuss Code: landing («abre tu carpeta») → IDE con la elfa integrada.
import { parseToolCall } from './agent.js';
import * as rules from './providers/rules.js';
import * as codeTools from './tools/code.js';
import * as db from './db.js';
import * as settings from './settings.js';
import { initEditor, refreshTree, openFile, gotoLine, triggerEditor, hasEditor } from './ide.js';
import { ACTIVITY, UI } from './icons.js';
import { renderMarkdown } from './md.js';
import * as skills from './skills.js';
import * as terminal from './terminal.js';
import * as shell from './shell.js';
import { setTerminalEcho } from './tools/index.js';
import { ensureModelCache, cacheEstimate, clearModelCache } from './model-cache.js';
import * as ceo from './ceo.js';
import * as mind from './mind.js';
import { buildCityAdapter, loadThoughtsAdapter } from './mind-adapter.js';
import { humanizeStreamPreview } from './humanize.js';
import { t, applyI18n } from './i18n.js';
import * as bridge from './bridge.js';
import * as conv from './conversations.js';
import { TASK_PREFIX } from './goal.js';
import * as telemetry from './telemetry.js';
const $ = id => document.getElementById(id);
telemetry.init('elffuss-code'); // opt-in, apagado por defecto — ver Ajustes
conv.setProvider(rules); // proveedor por defecto para cualquier conversación que se cree
let activeModel = 'rules';
// ---------- chat ----------
// Los `paths` entre backticks del chat (README.md, .elffuss/soul/x.md…) se
// vuelven clicables → abren el fichero en el editor directamente, sin tener
// que ir a buscarlos a mano.
function linkifyFilePaths(root) {
root.querySelectorAll('code').forEach(el => {
const t = el.textContent.trim();
if (!/^[\w.-]+(\/[\w.-]+)*\.[a-z0-9]{1,10}$/i.test(t) || /[{}()<>]/.test(t)) return;
el.classList.add('file-link');
el.title = 'abrir ' + t;
el.onclick = () => openFile(t);
});
}
function addMsg(cls, text) {
const div = document.createElement('div');
div.className = 'msg ' + cls;
// las respuestas se renderizan como markdown (estilo plugin de Claude Code);
// lo del usuario y los errores, como texto plano
if (/^(assistant$|sys)/.test(cls)) {
div.classList.add('md');
// cap defensivo: no volcar miles de nodos si una respuesta trae un archivo entero
div.innerHTML = renderMarkdown(text.length > 8000 ? text.slice(0, 8000) + '\n… (recortado)' : text);
linkifyFilePaths(div);
} else {
div.textContent = text;
}
$('chat-log').appendChild(div);
$('chat-log').scrollTop = $('chat-log').scrollHeight;
return div;
}
// estilo plugin de Claude Code: ● herramienta en negrita + detalle plegable
function addTool(tool, arg) {
const div = document.createElement('div');
div.className = 'msg tool';
const dot = document.createElement('span');
dot.className = 'tdot';
const b = document.createElement('b');
b.textContent = tool;
div.append(dot, b);
if (arg) {
const a = document.createElement('span');
a.className = 'targ';
a.textContent = String(arg).slice(0, 60);
div.appendChild(a);
}
$('chat-log').appendChild(div);
$('chat-log').scrollTop = $('chat-log').scrollHeight;
return div;
}
function addToolResult(result) {
const err = result.startsWith('ERROR');
const det = document.createElement('details');
det.className = 'msg tool-result' + (err ? ' err' : '');
const sum = document.createElement('summary');
sum.textContent = (err ? '⚠️ ' : '') + result.split('\n')[0].slice(0, 90);
const pre = document.createElement('pre');
pre.textContent = result.slice(0, 1500);
det.append(sum, pre);
$('chat-log').appendChild(det);
$('chat-log').scrollTop = $('chat-log').scrollHeight;
return det;
}
const escapeHtml = s => String(s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
// 🎯 Modo Objetivo: tarjeta de plan (planificador → lista de tareas →
// ejecutor). Un solo nodo por plan (mismo id), repintado in-place según
// llegan los eventos 'plan'/'plan_update'/'plan_complete' — así se ve la
// lista evolucionar en vivo en vez de reconstruirse entera cada vez.
const PLAN_ICO = { pending: '⏳', 'in-progress': '🔄', done: '✅', failed: '❌', skipped: '⏭️' };
const PLAN_STATUS_LABEL = { planning: 'planificando…', running: 'ejecutando…', done: 'completado', failed: 'con fallos' };
function renderPlanCard(plan) {
const domId = 'plan-' + plan.id;
let div = document.getElementById(domId);
if (!div) {
div = document.createElement('div');
div.id = domId;
div.className = 'msg plan-card';
$('chat-log').appendChild(div);
}
div.innerHTML =
`${escapeHtml(PLAN_STATUS_LABEL[plan.status] || plan.status)}` +
`
🎯 Objetivo: ${escapeHtml(plan.goal)}
` +
(plan.planText ? `
${escapeHtml(plan.planText)}
` : '') +
`
` +
plan.tasks.map(t =>
`
${PLAN_ICO[t.status] || '⏳'}` +
`
${escapeHtml(t.title)}
${escapeHtml(t.description)}
`
).join('') +
`
`;
$('chat-log').scrollTop = $('chat-log').scrollHeight;
return div;
}
export function thinkingBubble() {
const div = addMsg('thinking', '');
const label = document.createElement('span');
label.textContent = t('thinking');
const gen = document.createElement('div');
gen.className = 'gen';
div.append(label, gen);
let buf = '';
return {
tick(tok) {
buf += tok;
label.textContent = t('writing', { n: buf.length }); // caracteres, no tokens
// si el buffer entró en un bloque de tool-call, no enseñes el JSON crudo
// según va llegando: una frase humana («leyendo app.js…») en su lugar.
const preview = humanizeStreamPreview(buf);
gen.classList.toggle('tool-preview', !!preview);
gen.textContent = preview ? '⟐ ' + preview : (buf.length > 200 ? '…' : '') + buf.slice(-200);
$('chat-log').scrollTop = $('chat-log').scrollHeight;
},
tool(name) { buf = ''; gen.textContent = ''; label.textContent = t('using', { name }); },
remove() { div.remove(); },
};
}
// Conversaciones (pestañas, en paralelo, con su propia cola): la lógica de
// estado vive en conversations.js — aquí solo se pinta lo que corresponde.
let activeThinking = null;
function renderActiveLog() {
$('chat-log').replaceChildren();
const active = conv.getActive();
activeThinking = null;
if (!active) return;
for (const m of active.agent.history) {
if (m.role === 'user') {
if (m.content.startsWith('[resultado ')) {
const nl = m.content.indexOf('\n');
addToolResult(nl > 0 ? m.content.slice(nl + 1) : '');
} else if (m.content.startsWith(TASK_PREFIX)) {
// objetivo/tareas internas del modo Goal: no son burbujas sueltas —
// la tarjeta de plan las muestra todas. Se pinta justo en el punto
// donde se lanzó el objetivo, igual que aparece en directo.
if (m.content.startsWith(TASK_PREFIX + 'Objetivo: ') && active.plan) renderPlanCard(active.plan);
} else if (!m.content.startsWith('[…')) {
addMsg('user', m.content);
}
} else {
const call = parseToolCall(m.content);
if (call) addTool(call.tool, call.args?.path || call.args?.query || '');
else addMsg('assistant', m.content);
}
}
if (active.pumping) activeThinking = thinkingBubble();
}
// evento del módulo de conversaciones: 'switch' (cambiaste de pestaña o se
// creó una), 'tabs' (se abrió/cerró una pestaña), 'pumping' (empezó/terminó
// de procesar), 'event' (token/texto/tool/resultado/error de un turno real).
// Las conversaciones EN SEGUNDO PLANO nunca tocan el DOM visible — solo se
// refleja su punto de «ocupada» en la barra de pestañas.
function onConvEvent(kind, id, payload) {
if (kind !== 'event') renderTabsBar();
if (kind === 'switch') { renderActiveLog(); return; }
const active = conv.getActive();
if (!active || id !== active.id) return;
if (kind === 'pumping') {
$('btn-send').disabled = payload;
$('btn-send').classList.toggle('sending', payload);
if (payload) { if (!activeThinking) activeThinking = thinkingBubble(); }
else { activeThinking?.remove(); activeThinking = null; }
return;
}
if (kind === 'event') {
const ev = payload;
if (ev.type === 'token') activeThinking?.tick(ev.text);
if (ev.type === 'text') addMsg('assistant', ev.text);
if (ev.type === 'tool') { activeThinking?.tool(ev.call.tool); addTool(ev.call.tool, ev.call.args?.path || ev.call.args?.query || ''); }
if (ev.type === 'tool_result') addToolResult(ev.result);
if (ev.type === 'error') addMsg('assistant err', ev.text);
// 🎯 Modo Objetivo: la tarjeta de plan se crea con 'plan' y se repinta
// in-place en cada 'plan_update'/'plan_complete' (mismo nodo, por id).
if (ev.type === 'plan' || ev.type === 'plan_update' || ev.type === 'plan_complete') renderPlanCard(ev.plan);
}
}
function send(text) {
const active = conv.getActive();
if (!active) return;
addMsg('user', text);
if (goalMode) conv.startGoal(active.id, text);
else conv.send(active.id, text);
}
function renderTabsBar() {
const bar = $('conv-tabs');
if (!bar) return;
bar.replaceChildren();
const active = conv.getActive();
for (const c of conv.getOpenTabs()) {
const el = document.createElement('div');
el.className = 'tab' + (active && c.id === active.id ? ' active' : '') + (c.pumping ? ' tab-busy' : '');
el.dataset.id = c.id;
const name = document.createElement('span');
name.className = 'tab-name';
const title = conv.titleFor(c);
name.textContent = title;
name.title = title;
const x = document.createElement('b');
x.textContent = '×';
x.title = t('tabClose');
x.onclick = e => { e.stopPropagation(); conv.closeTab(c.id); };
el.append(name, x);
el.onclick = () => conv.switchTo(c.id);
bar.appendChild(el);
}
const plus = document.createElement('button');
plus.className = 'tab-add';
plus.title = t('newConv');
plus.textContent = '+';
plus.onclick = () => conv.create();
bar.appendChild(plus);
}
async function renderHistoryPanel() {
const panel = $('history-panel');
panel.replaceChildren();
const head = document.createElement('div');
head.className = 'hp-head';
head.append(document.createTextNode(t('tHistory')));
const xBtn = document.createElement('button');
xBtn.textContent = '✕';
xBtn.onclick = () => { panel.hidden = true; };
head.appendChild(xBtn);
panel.appendChild(head);
const list = document.createElement('div');
list.id = 'hp-list';
panel.appendChild(list);
const all = await conv.listAll();
if (!all.length) {
const empty = document.createElement('div');
empty.className = 'hp-empty';
empty.textContent = t('histEmpty');
list.appendChild(empty);
return;
}
for (const c of all) {
const row = document.createElement('div');
row.className = 'hp-row';
const titEl = document.createElement('div');
titEl.className = 'hp-title';
titEl.textContent = c.title || t('newConv');
const d = new Date(c.updatedAt);
const date = document.createElement('div');
date.className = 'hp-date';
date.textContent = d.toLocaleDateString() + ' ' + d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
const del = document.createElement('button');
del.className = 'hp-del';
del.title = t('histDel');
del.textContent = '🗑';
del.onclick = async e => {
e.stopPropagation();
if (!confirm(t('confirmDel', { title: c.title || t('thisConv') }))) return;
await conv.remove(c.id);
renderHistoryPanel();
};
row.append(titEl, date, del);
row.onclick = async () => { await conv.open(c.id); panel.hidden = true; };
list.appendChild(row);
}
}
// ---------- modelos (local primero; externos = ⚙️ opt-in) ----------
const localProviders = { litert: () => import('./providers/litert.js'), onnx: () => import('./providers/onnx.js') };
async function resolveProvider(id) {
if (localProviders[id]) return localProviders[id]();
if (id.startsWith('litert:')) { // Gemma vía LiteRT-LM (build elegido)
const mod = await import('./providers/litert.js');
mod.configure(id.slice(7));
return mod;
}
if (id.startsWith('ext:')) {
const cfg = settings.get(id.slice(4));
const mod = await import('./providers/api.js');
mod.configure(cfg);
return mod;
}
return null;
}
// El E4B healed (4.12 GB) NO carga aún en navegador (export prefill_decode, no
// artisan) — descargarlo y fallar deja la GPU en estado inválido y TUMBA la
// pestaña. Se oculta hasta el reexport artisan. Poner true para reactivarlo.
const LITERT_READY = false;
const isMobile = () => matchMedia('(max-width: 820px)').matches || matchMedia('(pointer: coarse)').matches;
// navigator.gpu puede EXISTIR como API sin que haya un adaptador real (ciertos
// Linux/drivers, entornos sandboxed/headless…) — comprobarlo UNA vez al
// arrancar evita ofrecer/elegir Gemma (2-4 GB) cuando de todos modos va a
// fallar al crear el motor (onnx.js y litert.js ya comprueban el adaptador
// por su cuenta como defensa adicional, pero esto evita hasta OFRECERLO).
let realGPU = !!navigator.gpu;
const realGPUCheck = (async () => {
if (!navigator.gpu) return (realGPU = false);
try { return (realGPU = !!(await navigator.gpu.requestAdapter())); }
catch { return (realGPU = false); }
})();
const defaultBrain = () => !realGPU ? 'onnx' : (isMobile() ? 'litert:gemma-e2b' : 'litert:gemma-e4b');
function modelOptions() {
const opts = [];
if (realGPU) opts.push({ id: 'litert:gemma-e4b', label: 'Gemma-4 E4B · LiteRT-LM (~4 GB) ★' });
if (realGPU) opts.push({ id: 'litert:gemma-e2b', label: 'Gemma-4 E2B · LiteRT-LM (~2 GB)' });
opts.push({ id: 'onnx', label: 'Elffuss LM (healed · 850 MB) — ligero' });
opts.push({ id: 'rules', label: 'Básico (sin modelo)' });
return [...opts, ...settings.enabledExternals()];
}
function rebuildSelect() {
const sel = $('model-select');
sel.replaceChildren();
for (const o of modelOptions()) {
const opt = document.createElement('option');
opt.value = o.id;
opt.textContent = o.label;
sel.appendChild(opt);
}
sel.value = activeModel;
}
// progreso del modelo: píldora prominente visible sobre landing e IDE
function showModelProgress(text, pct = null) {
const box = $('model-progress');
if (text == null) { box.hidden = true; return; }
box.hidden = false;
$('model-progress-text').textContent = text;
const bar = $('model-bar');
if (pct == null) {
bar.classList.add('indet'); // sin % conocido → barra animada
bar.style.width = '';
} else {
bar.classList.remove('indet');
bar.style.width = pct + '%';
}
}
let loadingId = null;
async function changeModel(id) {
if (id === 'rules') {
conv.setProvider(rules);
activeModel = 'rules';
localStorage.setItem('elffusscode.model', 'rules');
$('model-dot').className = 'dot off';
return true;
}
if (loadingId === id || activeModel === id) return true; // un solo modelo, una sola carga
loadingId = id;
$('model-dot').className = 'dot loading';
showModelProgress('Cargando el modelo IA…', 4);
try {
const mod = await resolveProvider(id);
await mod.load(p => {
if (typeof p === 'string') return showModelProgress(p);
if (p?.status === 'progress' && p.total) {
const pct = Math.round(p.loaded / p.total * 100);
showModelProgress(t('modelLoading', { pct, loaded: (p.loaded / 1e6 | 0), total: (p.total / 1e6 | 0) }), pct);
}
});
conv.setProvider(mod);
activeModel = id;
localStorage.setItem('elffusscode.model', id);
$('model-dot').className = 'dot on';
showModelProgress(t('modelReadyBadge'), 100);
setTimeout(() => showModelProgress(null), 2500);
$('statusbar').textContent = t('modelReady');
rebuildSelect();
return true;
} catch (e) {
console.error('[elffuss-code] fallo cargando', e);
// Un Gemma (LiteRT) que no cabe → cae al Elffuss LM healed (onnx, ligero).
if (id.startsWith('litert') && !_fellBack) {
_fellBack = true;
sessionStorage.setItem('elffusscode.skipGemma', '1');
showModelProgress(t('fallbackToast'));
loadingId = null;
const okc = await changeModel('onnx');
_fellBack = false;
if (okc) return true;
}
conv.setProvider(rules);
activeModel = 'rules';
$('model-dot').className = 'dot off';
showModelProgress('⚠️ No pude cargar el modelo: ' + (e?.message || e));
setTimeout(() => showModelProgress(null), 6000);
rebuildSelect();
return false;
} finally {
loadingId = null;
}
}
let _fellBack = false;
// El cerebro empieza a descargarse desde el PRIMER segundo, en segundo plano,
// mientras el usuario elige la carpeta. Solo el modelo local — los externos
// jamás se activan solos.
// Cadena de respaldo: el modelo preferido → LFM2.5 local → básico. Si el E4B
// healed falla (p. ej. «HF_Tokenizer_Zlib not supported» del runtime LiteRT),
// cae solo a LFM2.5 en vez de dejar al usuario sin cerebro.
async function preloadModel() {
const saved = localStorage.getItem('elffusscode.model');
if (saved === 'rules') return;
await realGPUCheck; // que defaultBrain()/modelOptions() vean el adaptador real, no solo la API
const avail = new Set(modelOptions().map(o => o.id));
const skipGemma = sessionStorage.getItem('elffusscode.skipGemma') === '1';
const def = skipGemma ? 'onnx' : defaultBrain();
const chain = [...new Set([saved, def, 'onnx']
.filter(id => id && id !== 'rules' && avail.has(id)))];
for (const id of chain) if (await changeModel(id)) return;
}
// ---------- panel ⚙️ Ajustes: modelo (cerebro) + API keys ----------
const el = (t, cls, txt) => { const e = document.createElement(t); if (cls) e.className = cls; if (txt != null) e.textContent = txt; return e; };
function settingsShell(title) {
const box = $('settings-panel');
box.hidden = false;
box.replaceChildren();
const close = el('button', 'panel-close'); close.innerHTML = UI.close; close.title = 'cerrar';
close.onclick = () => { box.hidden = true; };
box.append(close, el('h3', 'panel-title', title));
return box;
}
function showBridgeMsg(card, text, isErr) {
let el2 = card.querySelector('.br-msg');
if (!el2) { el2 = document.createElement('div'); el2.className = 'br-msg muted'; el2.style.cssText = 'font-size:.7rem;margin-top:6px'; card.appendChild(el2); }
el2.textContent = text;
el2.style.color = isErr ? '#ff6b8b' : '';
}
// pequeño estallido de fuegos artificiales (canvas, un par de segundos) para
// que se entienda de un vistazo que el bridge quedó conectado.
function fireworks() {
const cv = document.createElement('canvas');
cv.id = 'fireworks-fx';
cv.width = innerWidth; cv.height = innerHeight;
document.body.appendChild(cv);
const ctx = cv.getContext('2d');
const colors = ['#ff4d8d', '#7c5cff', '#49e8ff', '#3fb970', '#ffd479'];
const bursts = Array.from({ length: 5 }, () => ({
x: innerWidth * (0.2 + Math.random() * 0.6), y: innerHeight * (0.2 + Math.random() * 0.4),
t0: performance.now() + Math.random() * 500,
particles: Array.from({ length: 40 }, (_, i) => { const a = (i / 40) * Math.PI * 2; const sp = 2 + Math.random() * 3; return { a, sp, color: colors[i % colors.length] }; }),
}));
const start = performance.now();
function frame(now) {
ctx.clearRect(0, 0, cv.width, cv.height);
let alive = false;
for (const b of bursts) {
const age = (now - b.t0) / 1000;
if (age < 0) { alive = true; continue; }
if (age > 1.4) continue;
alive = true;
for (const p of b.particles) {
const r = p.sp * age * 40;
const x = b.x + Math.cos(p.a) * r, y = b.y + Math.sin(p.a) * r + age * age * 60;
ctx.globalAlpha = Math.max(0, 1 - age / 1.4);
ctx.fillStyle = p.color;
ctx.beginPath(); ctx.arc(x, y, 2.4, 0, Math.PI * 2); ctx.fill();
}
}
if (alive && now - start < 3000) requestAnimationFrame(frame);
else cv.remove();
}
requestAnimationFrame(frame);
}
function renderSettings() {
const box = settingsShell('Ajustes');
// --- Cerebro (modelo) ---
box.append(el('div', 'sk-h', 'Cerebro (modelo)'));
const LOCAL = [
{ id: 'litert:gemma-e4b', name: 'Gemma-4 E4B ★', sub: 'El mejor · WebGPU local · ~4 GB', need: 'gpu' },
{ id: 'litert:gemma-e2b', name: 'Gemma-4 E2B', sub: 'Ligero · WebGPU local · ~2 GB', need: 'gpu' },
{ id: 'onnx', name: 'Elffuss LM (healed)', sub: 'Modelo propio · 850 MB · tool-calls + apps' },
{ id: 'rules', name: 'Básico (sin modelo)', sub: 'Órdenes directas, cero descarga' },
];
const grid = el('div', 'model-grid');
for (const m of LOCAL) {
if (m.need === 'gpu' && !realGPU) continue;
const card = el('button', 'model-card' + (activeModel === m.id ? ' active' : '') + (m.disabled ? ' disabled' : ''));
if (m.disabled) card.disabled = true;
card.innerHTML = `${m.name}${m.sub}`;
card.onclick = () => { changeModel(m.id); renderSettings(); };
grid.appendChild(card);
}
box.appendChild(grid);
// --- Almacenamiento del modelo (caché persistente) ---
box.append(el('div', 'sk-h', t('setStoreTitle')));
const storeCard = el('div', 'prov-card');
const storeInfo = el('span', null, t('setCalc')); storeInfo.style.cssText = 'font-size:.8rem;color:var(--fg)';
const storeSub = el('div', 'field'); storeSub.style.marginTop = '2px';
const storeMuted = el('span', 'muted', ''); storeMuted.style.fontSize = '.72rem';
const clearBtn = el('button', 'prov-use', t('setClear'));
clearBtn.style.marginTop = '8px';
clearBtn.onclick = async () => { clearBtn.textContent = t('setClearing'); await clearModelCache(); await paintStorage(); clearBtn.textContent = t('setClear'); };
const storeHead = el('div', 'prov-head'); storeHead.append(storeInfo);
storeCard.append(storeHead, storeMuted, clearBtn);
box.appendChild(storeCard);
async function paintStorage() {
const { usage, quota, persisted } = await cacheEstimate();
const gb = n => (n / 1073741824).toFixed(2) + ' GB';
storeInfo.textContent = usage ? t('setCached', { gb: gb(usage) }) : t('setNone');
storeMuted.textContent = (persisted ? t('setPersist') : t('setNoPersist'))
+ (quota ? t('setLimit', { gb: gb(quota) }) : '');
}
paintStorage();
// --- Bridge local (ejecución REAL en tu máquina: node, npm, python…) ---
box.append(el('div', 'sk-h', '🔌 Bridge local (ejecución real en tu máquina)'));
const brCard = el('div', 'prov-card bridge-card');
const guessOS = () => {
const ua = navigator.userAgent;
if (/Mac/.test(ua)) return /Intel/.test(navigator.platform) && !/arm/i.test(ua) ? 'elffuss-bridge-mac-intel' : 'elffuss-bridge-mac-arm64';
if (/Win/.test(ua)) return 'elffuss-bridge-windows.exe';
if (/Linux/.test(ua)) return /aarch64|arm64/i.test(ua) ? 'elffuss-bridge-linux-arm64' : 'elffuss-bridge-linux';
return 'elffuss-bridge-mac-arm64';
};
const OTHER = { 'elffuss-bridge-mac-arm64': 'Mac (Apple Silicon)', 'elffuss-bridge-mac-intel': 'Mac (Intel)', 'elffuss-bridge-windows.exe': 'Windows', 'elffuss-bridge-linux': 'Linux (x64)', 'elffuss-bridge-linux-arm64': 'Linux (ARM)' };
const primary = guessOS();
brCard.innerHTML =
`
Bridge localdesconectado
` +
`
Un pequeño programa que TÚ ejecutas en tu ordenador — le da a la elfa ejecución real (node, npm, python…) sin salir de tu máquina. Nada se instala en el navegador.
` +
`⬇ Descargar para ${OTHER[primary]}` +
`otro sistema operativo` +
Object.entries(OTHER).filter(([f]) => f !== primary).map(([f, label]) => `
`).join('') +
`` +
`` +
`` +
``;
box.appendChild(brCard);
brCard.querySelector('#br-folder').value = bridge.getFolder();
brCard.querySelector('#br-token').value = localStorage.getItem('elffusscode.bridgeToken') || '';
const paintBridge = () => {
// el panel puede haberse cerrado entre sondeos de fondo — si ya no está
// en el DOM, nos desuscribimos en vez de tocar nodos huérfanos.
if (!document.body.contains(brCard)) { bridge.onStatusChange(() => {}); return; }
const on = bridge.isConnected();
brCard.querySelector('#br-dot').className = 'dot ' + (on ? 'on' : 'off');
brCard.querySelector('#br-status').textContent = on ? '✓ conectado — ejecución real activa' : 'desconectado';
if (on) brCard.querySelector('#br-token').value = localStorage.getItem('elffusscode.bridgeToken') || '';
};
paintBridge();
bridge.onStatusChange(paintBridge); // repinta solo si el bridge conecta/cae mientras Ajustes está abierto
brCard.querySelector('#br-connect').onclick = async () => {
const btn = brCard.querySelector('#br-connect');
bridge.setFolder(brCard.querySelector('#br-folder').value);
const token = brCard.querySelector('#br-token').value.trim();
if (!token) return showBridgeMsg(brCard, '⚠️ pega el token que imprimió el programa al arrancarlo', true);
btn.disabled = true; btn.textContent = 'Conectando…';
try { await bridge.connect(token); paintBridge(); showBridgeMsg(brCard, '✔ conectado — ejecución real en tu máquina'); fireworks(); }
catch (e) { showBridgeMsg(brCard, '⚠️ ' + e.message, true); }
finally { btn.disabled = false; btn.textContent = 'Conectar'; }
};
// --- Permisos de ejecución (mismo interruptor que > Auto de la barra) ---
box.append(el('div', 'sk-h', '✅ Permisos de ejecución'));
const permCard = el('div', 'prov-card');
permCard.innerHTML =
`` +
`
terminal.run ya se ejecuta siempre sin preguntar. Esto controla solo la escritura de ficheros (code.write/code.edit) — desmárcalo si prefieres revisar cada cambio antes de aplicarlo.
Igual que el botón 🎯 Goal de la barra — un planificador crea la lista de tareas y un ejecutor las va cumpliendo, marcando cada una como hecha o fallida (si una falla, las siguientes se saltan en vez de seguir a ciegas).
`;
box.appendChild(goalCard);
const goalCheckbox = goalCard.querySelector('#perm-goalmode');
goalCheckbox.checked = goalMode;
goalCheckbox.onchange = () => {
goalMode = goalCheckbox.checked;
localStorage.setItem('elffusscode.goalmode', goalMode ? '1' : '0');
paintGoal();
};
// --- 📨 Errores y feedback (opt-in — apagado no sale NADA de tu máquina) ---
box.append(el('div', 'sk-h', '📨 Errores y feedback'));
const telCard = el('div', 'prov-card');
telCard.innerHTML =
`` +
`
Apagado por defecto — tu código y el contenido de tus proyectos NUNCA se incluyen, solo el mensaje de error técnico, la pila y datos del navegador.
` +
`` +
`` +
`` +
``;
box.appendChild(telCard);
const telCheckbox = telCard.querySelector('#tel-enabled');
telCheckbox.checked = telemetry.isEnabled();
telCheckbox.onchange = () => telemetry.setEnabled(telCheckbox.checked);
telCard.querySelector('#tel-send').onclick = async () => {
const ta = telCard.querySelector('#tel-feedback');
const msg = telCard.querySelector('#tel-msg');
const text = ta.value.trim();
if (!text) { msg.textContent = 'escribe algo primero'; return; }
const wasEnabled = telemetry.isEnabled();
if (!wasEnabled) telemetry.setEnabled(true); // el envío manual es explícito, se permite aunque el automático esté apagado
await telemetry.sendFeedback(text);
if (!wasEnabled) telemetry.setEnabled(false); // no activa el automático de fondo si no lo pidió
ta.value = '';
msg.textContent = '¡enviado, gracias!';
setTimeout(() => { msg.textContent = ''; }, 4000);
};
// --- Proveedores externos (API keys) ---
box.append(el('div', 'sk-h', 'Proveedores externos (opcional · la clave se queda en tu navegador)'));
for (const [id, c] of Object.entries(settings.configs())) {
const card = el('div', 'prov-card' + (c.enabled ? ' on' : ''));
const head = el('div', 'prov-head');
const toggle = document.createElement('input'); toggle.type = 'checkbox'; toggle.checked = c.enabled;
const title = el('b', null, c.label);
const use = el('button', 'prov-use', 'Usar');
use.hidden = !c.enabled || activeModel === 'ext:' + id;
use.onclick = () => { changeModel('ext:' + id); renderSettings(); };
head.append(toggle, title, use);
const fields = el('div', 'prov-fields' + (c.enabled ? '' : ' hide'));
const model = document.createElement('input'); model.value = c.model || ''; model.placeholder = t('setModelPh');
const key = document.createElement('input'); key.type = 'password'; key.value = c.apiKey || '';
key.placeholder = c.kind === 'anthropic' ? 'sk-ant-…' : (id === 'ollama' || id === 'server' ? t('keyNeeded') : 'sk-…');
fields.append(labeled(t('lblModel'), model), labeled(t('lblApiKey'), key));
const save = () => {
settings.update(id, { enabled: toggle.checked, model: model.value.trim(), apiKey: key.value });
rebuildSelect();
};
toggle.onchange = () => { save(); renderSettings(); };
model.onchange = key.onchange = save;
card.append(head, fields);
box.appendChild(card);
}
// --- Skills ---
box.append(el('div', 'sk-h', 'Skills'));
const skBtn = el('button', 'primary wide', '🧩 Gestionar skills de Claude Code');
skBtn.textContent = 'Gestionar skills de Claude Code';
skBtn.onclick = openSkillsPanel;
box.appendChild(skBtn);
}
function labeled(label, input) {
const w = el('label', 'field'); w.append(el('span', 'muted', label), input); return w;
}
// ---------- arranque ----------
let galaxy = null;
async function enterIDE(handle) {
const name = await codeTools.openProject(handle);
shell.reset(); // el terminal arranca en la raíz del proyecto
galaxy?.stop();
$('landing').hidden = true;
$('ide').hidden = false;
$('project-name').textContent = name;
$('explorer-proj').textContent = name.toUpperCase();
await initEditor();
await refreshTree(handle);
// el sistema de conversaciones primero: renderActiveLog() REEMPLAZA todo
// #chat-log con el historial replay de la conversación activa — si el
// saludo se añadiera antes, desaparecería sin dejar rastro.
await conv.init({ onEvent: onConvEvent });
renderTabsBar();
renderActiveLog();
addMsg('sys', t('welcome', { name }));
refreshGit();
preloadModel(); // por si el arranque en la landing no llegó a dispararse
}
async function boot() {
applyI18n(); // localiza el chrome estático (placeholder) antes de pintar nada
// Caché de modelos (persistente + service worker) ANTES de descargar nada,
// para que hasta la primera descarga de pesos quede cacheada y no se repita.
ensureModelCache().finally(() => preloadModel());
bridge.tryAutoConnect(); // reconecta en silencio si ya se conectó antes (mismo token)
bridge.startAutoDetect(); // sigue sondeando en 2º plano: detecta el bridge si se arranca/reinicia más tarde
// galaxia en la landing (misma técnica que Elffuss)
import('./splash-gl.js').then(m => { galaxy = m.startGalaxy($('landing')); }).catch(() => {});
$('open-project').onclick = async () => {
try { await enterIDE(await window.showDirectoryPicker({ mode: 'readwrite' })); }
catch { /* cancelado */ }
};
// clonar un repo público (GitHub/Bitbucket): elige carpeta destino, se
// descarga ahí (fichero a fichero, sin git) y se abre como proyecto.
// RESUMIBLE: el trabajo (lista de ficheros + cuáles ya se bajaron + el
// handle de la carpeta) se persiste en IndexedDB — si el usuario navega
// atrás, cierra la pestaña o recarga a medias, al volver se ofrece
// «Reanudar» en vez de forzar a empezar de cero.
const cloneUrl = $('clone-url'), cloneBtn = $('clone-btn'), cloneStatus = $('clone-status');
const showCloneStatus = (text, isErr) => { cloneStatus.textContent = text; cloneStatus.hidden = !text; cloneStatus.classList.toggle('err', !!isErr); };
const saveCloneJob = job => db.set('kv', 'cloneJob', job).catch(() => {});
const loadCloneJob = () => db.get('kv', 'cloneJob').catch(() => null);
const clearCloneJob = () => db.del('kv', 'cloneJob').catch(() => {});
async function runDownload(job) {
cloneBtn.disabled = true;
const doneSet = new Set(job.done);
const clone = await import('./tools/clone.js');
try {
await clone.downloadFiles(job, job.handle, doneSet,
p => showCloneStatus(p.text || ''),
async path => {
job.done.push(path);
// persistir el progreso poco a poco (no en cada fichero: de sobra
// para no perder más de un puñado si se interrumpe a media descarga)
if (job.done.length % 5 === 0) await saveCloneJob(job);
});
await saveCloneJob(job);
showCloneStatus(`✔ ${job.files.length} ficheros descargados` + (job.skipped ? ` (${job.skipped} omitidos por tamaño)` : ''));
await clearCloneJob();
await enterIDE(job.handle);
} catch (e) {
await saveCloneJob(job); // que lo ya bajado no se pierda aunque falle a mitad
showCloneStatus('⚠️ ' + (e?.message || e) + ' — puedes reintentar, retoma donde se quedó', true);
} finally { cloneBtn.disabled = false; }
}
const runClone = async () => {
const url = cloneUrl.value.trim();
if (!url) return;
let handle;
try { handle = await window.showDirectoryPicker({ mode: 'readwrite' }); }
catch { return; } // cancelado, sin error
showCloneStatus('Listando el repo…');
try {
const clone = await import('./tools/clone.js');
const info = await clone.listRepo(url);
const job = { url, handle, done: [], ...info };
await saveCloneJob(job);
await runDownload(job);
} catch (e) {
showCloneStatus('⚠️ ' + (e?.message || e), true);
}
};
cloneBtn.addEventListener('click', runClone);
cloneUrl.addEventListener('keydown', e => { if (e.key === 'Enter') runClone(); });
// ¿había una descarga a medias de una sesión anterior? ofrece reanudarla
const job = await loadCloneJob();
if (job && job.handle && job.files && job.done.length < job.files.length) {
const banner = $('resume-clone');
banner.hidden = false;
banner.querySelector('.rc-text').textContent = `Descarga a medias de ${job.owner}/${job.repo} (${job.done.length}/${job.files.length} ficheros)`;
$('resume-clone-btn').onclick = async () => {
banner.hidden = true;
try {
const q = job.handle.queryPermission ? await job.handle.queryPermission({ mode: 'readwrite' }) : 'granted';
if (q !== 'granted' && await job.handle.requestPermission({ mode: 'readwrite' }) !== 'granted') throw new Error('permiso denegado para la carpeta');
showCloneStatus(`Reanudando ${job.owner}/${job.repo}…`);
await runDownload(job);
} catch (e) { showCloneStatus('⚠️ ' + (e?.message || e), true); }
};
$('discard-clone-btn').onclick = async () => { await clearCloneJob(); banner.hidden = true; };
} else if (job) {
await clearCloneJob(); // job corrupto o ya completo: limpiar
}
// gancho de test: ?test-opfs usa el almacenamiento del navegador como proyecto
if (location.search.includes('test-opfs')) {
const opfs = await navigator.storage.getDirectory();
return enterIDE(opfs);
}
// reabrir el último proyecto (un clic si el navegador exige gesto)
const prev = await codeTools.restoreProject();
if (prev?.ready) return enterIDE(await db.get('kv', 'project'));
if (prev?.handle) {
const btn = $('reopen-project');
btn.hidden = false;
btn.textContent = `Reabrir «${prev.name}»`;
btn.onclick = async () => {
try { await codeTools.regrant(prev.handle); await enterIDE(prev.handle); }
catch (e) { $('statusbar').textContent = '⚠️ ' + e.message; }
};
}
}
// Historial del prompt del chat: ↑/↓ recuperan lo enviado (como una shell).
// Persistente entre recargas (localStorage, últimos 100).
const PROMPT_HIST_KEY = 'elffusscode.promptHistory';
let promptHistory = [];
try { promptHistory = JSON.parse(localStorage.getItem(PROMPT_HIST_KEY) || '[]'); } catch { /* corrupto */ }
let promptHistIdx = -1, promptDraft = '';
function pushPromptHistory(text) {
if (promptHistory[promptHistory.length - 1] !== text) promptHistory.push(text);
if (promptHistory.length > 100) promptHistory = promptHistory.slice(-100);
try { localStorage.setItem(PROMPT_HIST_KEY, JSON.stringify(promptHistory)); } catch { /* lleno */ }
promptHistIdx = -1; promptDraft = '';
}
$('prompt').addEventListener('keydown', e => {
if (!$('menu').hidden) return; // el menú «/» ya usa ↑/↓
const inp = e.target;
if (e.key === 'ArrowUp') {
if (!promptHistory.length) return;
if (promptHistIdx === -1) { promptDraft = inp.value; promptHistIdx = promptHistory.length; }
promptHistIdx = Math.max(0, promptHistIdx - 1);
inp.value = promptHistory[promptHistIdx];
e.preventDefault();
requestAnimationFrame(() => inp.setSelectionRange(inp.value.length, inp.value.length));
} else if (e.key === 'ArrowDown') {
if (promptHistIdx === -1) return;
promptHistIdx++;
inp.value = promptHistIdx >= promptHistory.length ? (promptHistIdx = -1, promptDraft) : promptHistory[promptHistIdx];
e.preventDefault();
} else if (e.key === 'Enter' && !e.isComposing && !e.shiftKey) {
// envío explícito: no depender del submit-on-Enter nativo del