elffuss-code / js /main.js
KikoCis's picture
ACE v2: BM25 con IDF endogena sin heuristicas + reserva de cabecera + embeddings opcionales tras bandera. Medido: recall de hechos 15,1% -> 65,3%; el encargo original pasa de perderse siempre a conservarse siempre.
51ec7b5 verified
Raw
History Blame Contribute Delete
72.3 kB
// 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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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 =
`<span class="plan-status">${escapeHtml(PLAN_STATUS_LABEL[plan.status] || plan.status)}</span>` +
`<div class="plan-head">🎯 Objetivo: ${escapeHtml(plan.goal)}</div>` +
(plan.planText ? `<div class="plan-summary">${escapeHtml(plan.planText)}</div>` : '') +
`<ul class="plan-tasks">` +
plan.tasks.map(t =>
`<li class="task-${t.status}"><span class="task-ico">${PLAN_ICO[t.status] || '⏳'}</span>` +
`<div><div class="task-title">${escapeHtml(t.title)}</div><div class="task-desc">${escapeHtml(t.description)}</div></div></li>`
).join('') +
`</ul>`;
$('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 = `<b>${m.name}</b><span>${m.sub}</span>`;
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 =
`<div class="prov-head"><span id="br-dot" class="dot off"></span><b>Bridge local</b><span id="br-status" class="muted" style="margin-left:auto;font-size:.72rem">desconectado</span></div>` +
`<p class="muted" style="font-size:.72rem;margin:6px 0">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.</p>` +
`<a class="prov-use" style="text-decoration:none;display:inline-block" href="bridge-dl/${primary}" download>⬇ Descargar para ${OTHER[primary]}</a>` +
`<details style="margin-top:6px"><summary class="muted" style="font-size:.7rem;cursor:pointer">otro sistema operativo</summary>` +
Object.entries(OTHER).filter(([f]) => f !== primary).map(([f, label]) => `<div><a href="bridge-dl/${f}" download style="color:var(--accent2);font-size:.72rem">${label}</a></div>`).join('') +
`</details>` +
`<div class="field" style="margin-top:8px"><label class="muted" style="font-size:.68rem">${t('setBrToken')}</label><input id="br-token" placeholder="${t('brTokenPh')}"></div>` +
`<div class="field" style="margin-top:6px"><label class="muted" style="font-size:.68rem">Carpeta de trabajo (opcional — si no, usa una temporal)</label><input id="br-folder" placeholder="/ruta/completa/a/tu/proyecto"></div>` +
`<button id="br-connect" class="prov-use" style="margin-top:8px">Conectar</button>`;
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 =
`<label style="display:flex;align-items:center;gap:8px;cursor:pointer">` +
`<input type="checkbox" id="perm-autoedit"> ` +
`<span>Ejecutar todo automáticamente (escribir/editar ficheros y comandos de terminal), sin pedir confirmación</span>` +
`</label>` +
`<p class="muted" style="font-size:.7rem;margin:6px 0 0">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.</p>`;
box.appendChild(permCard);
const permCheckbox = permCard.querySelector('#perm-autoedit');
permCheckbox.checked = autoEdit;
permCheckbox.onchange = () => {
autoEdit = permCheckbox.checked;
localStorage.setItem('elffusscode.autoedit', autoEdit ? '1' : '0');
paintAuto();
};
// --- 🎯 Modo Objetivo (planificador + ejecutor, mismo patrón que Auto) ---
box.append(el('div', 'sk-h', '🎯 Modo Objetivo'));
const goalCard = el('div', 'prov-card');
goalCard.innerHTML =
`<label style="display:flex;align-items:center;gap:8px;cursor:pointer">` +
`<input type="checkbox" id="perm-goalmode"> ` +
`<span>Tratar el próximo mensaje como un objetivo: lo descompone en tareas y las ejecuta una a una</span>` +
`</label>` +
`<p class="muted" style="font-size:.7rem;margin:6px 0 0">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).</p>`;
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 =
`<label style="display:flex;align-items:center;gap:8px;cursor:pointer">` +
`<input type="checkbox" id="tel-enabled"> ` +
`<span>Enviar automáticamente los errores técnicos que ocurran, para poder arreglarlos</span>` +
`</label>` +
`<p class="muted" style="font-size:.7rem;margin:6px 0 10px">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.</p>` +
`<label class="muted" style="font-size:.68rem">O manda algo tú directamente (un fallo que viste, algo que eches en falta…)</label>` +
`<textarea id="tel-feedback" rows="2" style="width:100%;margin-top:4px;resize:vertical;background:var(--bg2);border:1px solid var(--line);border-radius:8px;color:var(--fg);padding:6px 8px;font:inherit" placeholder="${t('setFeedbackPh')}"></textarea>` +
`<button id="tel-send" class="prov-use" style="margin-top:6px">Enviar</button>` +
`<span id="tel-msg" class="muted" style="font-size:.7rem;margin-left:8px"></span>`;
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 <form>,
// que algún navegador puede "tragarse" el primer Enter (autocompletar/
// sugerencias) justo después de rellenar el valor por código (↑ del
// historial) en vez de por tecleo real.
e.preventDefault();
$('composer').requestSubmit();
}
});
$('composer').addEventListener('submit', e => {
e.preventDefault();
const text = $('prompt').value.trim();
if (!text) return;
$('prompt').value = '';
pushPromptHistory(text);
send(text);
});
$('model-select').addEventListener('change', e => changeModel(e.target.value));
// móvil: conmutar chat ↔ editor
$('code-flip').addEventListener('click', () => {
const showEditor = !document.body.classList.contains('show-editor');
document.body.classList.toggle('show-editor', showEditor);
paintFlip(showEditor);
});
// vistas Arquitectura (grafo) y Ciudad 3D — leen el proyecto abierto y al
// hacer clic abren el fichero en Monaco (inspirado en CodeFlow + VibeCodeViewer)
$('act-arch').innerHTML = UI.graph || UI.code;
$('act-city').innerHTML = UI.city || UI.code;
let viewRenderSeq = 0;
async function openView(kind) {
const overlay = $('view-overlay'), body = $('view-body');
const seq = ++viewRenderSeq;
// destruir CUALQUIER vista previa (su RAF/canvas seguía vivo al cambiar → la
// nueva no cargaba bien). Ambas, no solo la del mismo tipo.
try { (await import('./city.js')).disposeCity(); } catch { /* */ }
try { (await import('./arch.js')).disposeArch(); } catch { /* */ }
body.innerHTML = '<div class="view-loading">Construyendo…</div>';
overlay.hidden = false;
document.querySelectorAll('#activity button').forEach(b => b.classList.toggle('on', b.id === 'act-' + kind));
// esperar a que el layout dé tamaño real al overlay (si no, canvas 0×0/mal aspect)
await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)));
if (seq !== viewRenderSeq) return; // el usuario cambió de vista mientras tanto
try {
if (kind === 'arch') { const m = await import('./arch.js'); await m.renderArchitecture(body, p => { closeView(); openFile(p); }); }
else { const m = await import('./city.js'); await m.renderCity(body, p => { closeView(); openFile(p); }); }
} catch (e) { body.innerHTML = '<div class="view-loading">No pude construir la vista: ' + (e?.message || e) + '</div>'; }
}
function closeView() {
$('view-overlay').hidden = true;
import('./city.js').then(m => m.disposeCity()).catch(() => {});
import('./arch.js').then(m => m.disposeArch()).catch(() => {});
document.querySelectorAll('#activity button').forEach(b => b.classList.toggle('on', b.id === 'act-files'));
}
$('act-arch').addEventListener('click', () => openView('arch'));
$('act-city').addEventListener('click', () => openView('city'));
$('view-close').addEventListener('click', closeView);
// ── Cerebro CEO autónomo + Mente de Elffuss ───────────────────────────────
// Cuando NO estás pidiendo nada, la elfa trabaja: revisa el proyecto y propone
// mejoras (en elffuss-mind/, sin tocar tu código). Clic en la elfa → abre la
// «Mente» (mundo trance + pensamientos paralelos + música) y activa el cerebro.
mind.setOpenFile(openFile);
mind.init({ buildCity: buildCityAdapter, loadThoughts: loadThoughtsAdapter });
// «Ejecutar esta propuesta»: la manda a la MISMA cola/agente del chat normal
// (mismas herramientas reales, mismo gate de Auto-edit) — no un auto-apply
// aparte sin supervisión.
mind.setExecuteProposal(md => { mind.closeMind(); send(t('pProposal') + md); });
// «perfiles»: cada uno es una línea de pensamiento paralela (foco + color de
// su estrella) del cerebro CEO (compartido, core/ceo.js). El usuario los
// edita/crea/borra desde ⚙ en la Mente.
const CEO_DEFAULT_PROFILES = [
{ id: 'arq', name: 'Arquitectura', focus: 'estructura, acoplamiento y dependencias; qué módulo conviene dividir o unificar', color: '#7c5cff' },
{ id: 'cal', name: 'Calidad', focus: 'bugs latentes, casos borde sin cubrir, validaciones que faltan', color: '#49e8ff' },
{ id: 'rend', name: 'Rendimiento', focus: 'cuellos de botella, trabajo repetido, estructuras de datos mejorables', color: '#ffb454' },
{ id: 'dx', name: 'Producto/DX', focus: 'legibilidad, nombres, documentación y ergonomía para quien lo usa', color: '#3fb970' },
];
// adaptador de workspace para el cerebro CEO genérico (core/ceo.js): traduce
// sus operaciones a las herramientas de code.js (proyecto de código único).
async function ceoDirHandle(dirPath) {
let dir = codeTools.handle();
for (const seg of dirPath.split('/').filter(Boolean)) dir = await dir.getDirectoryHandle(seg, { create: true });
return dir;
}
const ceoWorkspace = {
isReady: () => !!codeTools.handle(),
tree: (opts) => codeTools.tree(opts),
write: (opts) => codeTools.write(opts),
read: (opts) => codeTools.read(opts),
list: async (dirPath) => {
const dir = await ceoDirHandle(dirPath);
const names = [];
for await (const e of dir.values()) if (e.kind === 'file') names.push(e.name);
return names;
},
remove: async (dirPath, name) => (await ceoDirHandle(dirPath)).removeEntry(name),
};
ceo.init({
namespace: 'elffusscode',
workspace: ceoWorkspace,
defaultProfiles: CEO_DEFAULT_PROFILES,
defaultMission: 'Revisar el proyecto y proponer mejoras concretas y accionables (código, procesos, datos o docs).',
provider: () => conv.getProvider(),
// el usuario tiene PRIORIDAD: si CUALQUIER conversación está en cola o procesándose, el cerebro espera
isBusy: () => conv.isBusy(),
onEvent: (ch, ev) => {
mind.pushThought(ch, ev);
if (ch === 'ceo' && ev.type === 'built') reportImprovements(ev);
// la elfa se anima mientras el cerebro trabaja (invita a hacer clic para ver la Mente)
const av = document.querySelector('#activity img');
if (av) {
if (ch === 'ceo' && ev.type === 'cycle') av.classList.add('working');
if (ch === 'ceo' && (ev.type === 'built' || ev.type === 'paused')) av.classList.remove('working');
}
},
});
// Reporta las mejoras SIEMPRE en el chat (visual, entendible); la notificación
// del navegador solo salta si de verdad hay algo interesante y bien trabajado
// — nada de avisar por un ciclo flojo con una propuesta de una línea.
const WEAK_RE = /^(no (encontr|hay|se me ocurre)|nada que|sin cambios|todo (está|esta) bien|no aplica)/i;
export function isNoteworthy(ev) {
const solid = (ev.proposals || []).filter(p => p.text && p.text.trim().length >= 60 && !WEAK_RE.test(p.text.trim()));
return solid.length >= 2; // al menos 2 departamentos con algo sustancioso, no una ocurrencia suelta
}
// manda una propuesta (el .md completo del ciclo) a la MISMA cola/agente real
// del chat — usado tanto por el botón del chat como por el de la notificación.
function executeProposal(md) { send(t('pProposal') + md); }
export function reportImprovements(ev) {
const props = ev.proposals || [];
if (!props.length) return;
const firstLine = t => (t || '').split('\n').find(l => l.trim())?.trim().slice(0, 160) || '';
const body = `💡 **Mientras estabas fuera revisé el proyecto y encontré ${props.length} mejora${props.length === 1 ? '' : 's'}:**\n\n` +
props.map(p => `**${p.dept}** — ${firstLine(p.text)}`).join('\n\n') +
(ev.path ? `\n\n_Guardado en \`${ev.path}\`. Dale a la elfa para verlo en la Mente, o ábrelo en el editor._` : '');
const div = addMsg('assistant', body);
// se guarda en el historial REAL de la conversación activa — si no, el
// aviso vive solo en el DOM y desaparece al cambiar de pestaña o recargar
const active = conv.getActive();
if (active) conv.appendMessage(active.id, 'assistant', body);
// botón REAL para ejecutar la propuesta desde el propio chat (antes solo
// se podía desde el panel de la Mente, había que ir a buscarlo)
const md = ev.md || body;
if (div) {
const btn = document.createElement('button');
btn.className = 'proposal-exec-btn';
btn.textContent = '▶ Ejecutar esta propuesta';
btn.onclick = () => { btn.disabled = true; btn.textContent = '▶ enviado a la cola…'; executeProposal(md); };
div.appendChild(btn);
}
if (isNoteworthy(ev)) notify(`Elffuss encontró ${props.length} mejora${props.length === 1 ? '' : 's'}`, props.map(p => p.dept).join(' · '), md);
}
// SOLO notifica si el permiso YA está concedido. Nunca pide permiso aquí:
// fuera de un gesto de usuario, Chrome/Firefox lo bloquean en silencio —
// pedirlo vive únicamente en el clic de la elfa y en ⚙ de la Mente.
// Vía el service worker (registration.showNotification) para poder incluir
// un botón «▶ Ejecutar» de verdad en la propia notificación del sistema
// (new Notification() normal no soporta acciones); si el SW no está listo,
// cae a la notificación simple sin botón.
async function notify(title, body, md) {
if (!('Notification' in window) || Notification.permission !== 'granted') return;
try {
if ('serviceWorker' in navigator) {
const reg = await navigator.serviceWorker.ready;
if (reg.showNotification) {
await reg.showNotification(title, {
body, icon: 'img/elffuss-code.svg',
actions: [{ action: 'execute', title: t('nExec') }, { action: 'open', title: t('nView') }],
data: { md },
});
return;
}
}
} catch { /* cae a la notificación simple */ }
try { new Notification(title, { body, icon: 'img/elffuss-code.svg' }); } catch { /* */ }
}
// clic en el botón de la notificación del sistema → lo mismo que en el chat
if ('serviceWorker' in navigator) {
navigator.serviceWorker.addEventListener('message', e => {
if (e.data?.type !== 'notif-action') return;
if (e.data.action === 'execute' && e.data.md) executeProposal(e.data.md);
else mind.openMind();
});
}
// cualquier interacción FUERA de la Mente cuenta como actividad → pausa el CEO
const noteAct = e => { if (!e.target.closest?.('#mind-overlay')) ceo.noteActivity(); };
document.addEventListener('pointerdown', noteAct, true);
document.addEventListener('keydown', noteAct, true);
const elfAvatar = document.querySelector('#activity img');
if (elfAvatar) {
elfAvatar.style.cursor = 'pointer';
elfAvatar.title = t('avatarTitle');
elfAvatar.addEventListener('click', () => {
// auto-activa SOLO la primera vez (nunca decidiste play/stop); si lo
// pausaste a propósito, abrir la Mente NO debe reactivarlo por sorpresa.
if (!ceo.isEnabled() && !ceo.hasDecided()) ceo.enable();
try { if ('Notification' in window && Notification.permission === 'default') Notification.requestPermission(); } catch { /* */ }
mind.openMind();
});
}
// restaura tu última elección (play o stop) tal cual la dejaste
if (ceo.wasEnabledLastSession()) ceo.enable();
// terminal integrado: xterm.js + shell sobre los ficheros REALES del proyecto.
// La elfa comparte este shell (tool terminal.run) y su salida se refleja aquí.
$('act-term').innerHTML = UI.terminal;
shell.setHooks({ fsChange: () => refreshTree().catch(() => {}), openFile });
setTerminalEcho((cmd, out) => terminal.echoAgent(cmd, out));
async function toggleTerminal(force) {
const show = force != null ? force : $('terminal-panel').hidden;
$('terminal-panel').hidden = !show;
$('act-term').classList.toggle('on', show);
if (show) {
const cap = shell.capabilities();
$('term-caps').textContent = cap.bridge ? '🔌 Bridge local: node/npm/python reales' : 'shell del proyecto · node/npm/python → Bridge local (⚙ Ajustes)';
await terminal.mount($('terminal-host'));
terminal.refit();
}
}
$('act-term').addEventListener('click', () => toggleTerminal());
$('term-close').addEventListener('click', () => toggleTerminal(false));
$('term-clear').addEventListener('click', () => terminal.clearScreen());
// Ctrl+` (o Cmd+`) alterna el terminal, como en VS Code
document.addEventListener('keydown', e => {
if ((e.ctrlKey || e.metaKey) && e.key === '`') { e.preventDefault(); toggleTerminal(); }
});
// redimensionar arrastrando la barra superior del panel
(() => {
const panel = $('terminal-panel'), handle = $('term-resize');
let sy = 0, sh = 0;
const move = e => { const h = Math.max(120, Math.min(window.innerHeight - 160, sh + (sy - e.clientY))); panel.style.height = h + 'px'; terminal.refit(); };
const up = () => { document.removeEventListener('mousemove', move); document.removeEventListener('mouseup', up); };
handle.addEventListener('mousedown', e => { sy = e.clientY; sh = panel.offsetHeight; document.addEventListener('mousemove', move); document.addEventListener('mouseup', up); e.preventDefault(); });
})();
window.addEventListener('resize', () => terminal.refit());
// iconos VS Code (sin emojis) en cabecera, composer y flip
$('btn-history').innerHTML = UI.history;
$('btn-settings').innerHTML = UI.gear;
$('btn-send').innerHTML = UI.send;
$('btn-plus').innerHTML = UI.add;
$('btn-slash').innerHTML = UI.slash;
const paintFlip = editor => { $('code-flip').innerHTML = (editor ? UI.chat : UI.editor) + `<span>${editor ? 'Chat' : 'Editor'}</span>`; };
paintFlip(false);
// barra de actividad estilo VS Code
$('act-files').innerHTML = ACTIVITY.files;
$('act-search').innerHTML = ACTIVITY.search;
$('act-settings').innerHTML = ACTIVITY.gear;
$('act-files').addEventListener('click', () => {
document.body.classList.toggle('hide-tree');
$('act-files').classList.toggle('on');
});
$('act-search').addEventListener('click', () => {
$('prompt').value = 'busca ';
$('prompt').focus();
});
$('act-settings').addEventListener('click', () => $('btn-settings').click());
$('btn-history').addEventListener('click', () => {
const panel = $('history-panel');
panel.hidden = !panel.hidden;
if (!panel.hidden) renderHistoryPanel();
});
document.addEventListener('pointerdown', e => {
const p = $('history-panel');
if (p.hidden) return;
if (p.contains(e.target) || e.target.closest('#btn-history')) return;
p.hidden = true;
}, true);
$('btn-settings').addEventListener('click', () => {
const p = $('settings-panel');
p.hidden = !p.hidden;
if (!p.hidden) renderSettings();
});
// clic fuera del panel (o ir a Archivo/chat) lo cierra — antes solo la X lo hacía
document.addEventListener('pointerdown', e => {
const p = $('settings-panel');
if (p.hidden) return;
if (p.contains(e.target) || e.target.closest('#btn-settings, #act-settings')) return;
p.hidden = true;
}, true);
// ---------- composer estilo plugin: +, /, medidor de contexto, Auto ----------
// medidor de contexto: chars del historial / presupuesto (~4 chars/token)
// Al máximo del modelo de serie: LFM2.5-1.2B soporta 32K nativo; reservamos
// ~2K de sistema + 2K de generación → 28K para historial.
const CTX_BUDGET_TOK = 28000;
function updateCtxMeter() {
if (!$('ctx-text')) return; // aún en la landing
const chars = (conv.getActive()?.agent.history || []).reduce((s, m) => s + (m.content || '').length, 0);
const tok = Math.round(chars / 4);
const pct = Math.min(100, Math.round(tok / CTX_BUDGET_TOK * 100));
const k = n => n >= 1000 ? (n / 1000).toFixed(1).replace('.0', '') + 'k' : n;
$('ctx-text').textContent = `ctx ${k(tok)} / ${k(CTX_BUDGET_TOK)}`;
$('ctx-bar').style.width = pct + '%';
$('ctx-bar').className = pct > 85 ? 'hot' : '';
$('ctx-meter').title = pct > 85
? `Contexto casi lleno (${pct}%). Los mensajes viejos se comprimen solos; usa 🧹 para empezar limpio.`
: `Contexto: ${tok} de ~${CTX_BUDGET_TOK} tokens (${100 - pct}% libre)`;
}
const _origAdd = addMsg; // refrescar el medidor cuando cambia la conversación
addMsg = (...a) => { const r = _origAdd(...a); updateCtxMeter(); return r; };
// menú flotante genérico sobre el composer
function openMenu(items) {
const menu = $('menu');
menu.replaceChildren();
for (const it of items) {
if (it.sep) { const s = document.createElement('div'); s.className = 'sep'; s.textContent = it.sep; menu.appendChild(s); continue; }
const row = document.createElement('button');
row.className = 'menu-item';
row.innerHTML = `<b>${it.label}</b>${it.hint ? `<span>${it.hint}</span>` : ''}`;
row.onclick = () => { menu.hidden = true; it.run(); };
menu.appendChild(row);
}
menu.hidden = false;
}
document.addEventListener('click', e => {
if (!e.target.closest('#menu, #btn-slash, #btn-plus')) $('menu').hidden = true;
});
// [/] comandos
$('btn-slash').addEventListener('click', () => openMenu([
{ sep: t('cmdsSep') },
{ label: '/tree', hint: t('slTree'), run: () => send(t('pTree')) },
{ label: '/search', hint: t('slSearch'), run: () => { $('prompt').value = t('pSearch'); $('prompt').focus(); } },
{ label: '/skills', hint: t('slSkills'), run: () => openSkillsPanel() },
{ label: '/model', hint: t('slModel'), run: () => $('btn-settings').click() },
{ label: '/clear', hint: t('slClear'), run: () => conv.create() },
]));
// [+] adjuntar archivo del proyecto como @ruta
$('btn-plus').addEventListener('click', async () => {
let tree = '';
try { tree = await codeTools.tree({ depth: 3 }); } catch { /* sin proyecto */ }
const files = tree.split('\n').filter(l => l.trim() && !l.includes('📁')).map(l => l.trim()).slice(0, 40);
const { currentFile } = codeTools.current();
const items = [{ sep: t('attachSep') }];
if (currentFile) items.push({ label: '@' + currentFile, hint: t('attachOpen'), run: () => attach(currentFile) });
for (const f of files.filter(f => f !== currentFile).slice(0, 20))
items.push({ label: '@' + f, run: () => attach(f) });
openMenu(items.length > 1 ? items : [{ sep: t('openProjFirst') }]);
});
function attach(path) {
const p = $('prompt');
p.value = (p.value + ' @' + path).trim() + ' ';
p.focus();
}
// </> Auto (Edit automatically): si off, pide confirmación antes de escribir
let autoEdit = localStorage.getItem('elffusscode.autoedit') !== '0';
function paintAuto() {
const b = $('btn-autoedit');
b.classList.toggle('on', autoEdit);
b.querySelector('.ae-txt').textContent = autoEdit ? t('autoTxt') : t('reviewTxt');
b.title = autoEdit ? t('autoOn') : t('autoOff');
}
$('btn-autoedit').addEventListener('click', () => {
autoEdit = !autoEdit;
localStorage.setItem('elffusscode.autoedit', autoEdit ? '1' : '0');
paintAuto();
});
paintAuto();
codeTools.setWriteApprover(async (path, content) => {
if (autoEdit) return true;
return confirm(`Elffuss quiere escribir en «${path}» (${content.split('\n').length} líneas).\n\n¿Aplicar el cambio?`);
});
// 🎯 Goal: el siguiente mensaje que mandes se trata como un OBJETIVO — se
// planifica en tareas y se ejecutan una a una (goal.js), en vez del turno de
// chat normal. Mismo patrón (interruptor persistente en localStorage) que
// </> Auto de arriba.
let goalMode = localStorage.getItem('elffusscode.goalmode') === '1';
function paintGoal() {
$('btn-goal').classList.toggle('on', goalMode);
}
$('btn-goal').addEventListener('click', () => {
goalMode = !goalMode;
localStorage.setItem('elffusscode.goalmode', goalMode ? '1' : '0');
paintGoal();
});
paintGoal();
// resolver @rutas del mensaje: se leen y se adjuntan como contexto
const _send = send;
send = async (text) => {
const refs = [...text.matchAll(/@([\w./-]+\.\w+)/g)].map(m => m[1]);
if (refs.length) {
let ctx = '';
for (const r of [...new Set(refs)].slice(0, 4)) {
try { ctx += `\n\n--- ${r} ---\n${await codeTools.read({ path: r })}`; } catch { /* ignora */ }
}
if (ctx) text += '\n\n[archivos adjuntos]' + ctx;
}
_send(text);
};
// ---------- panel de skills (transparente: repo + lista + SKILL.md) ----------
async function openSkillsPanel() {
const box = $('settings-panel');
box.hidden = false;
box.replaceChildren();
const close = document.createElement('button');
close.id = 'settings-close'; close.textContent = '✕'; close.onclick = () => { box.hidden = true; };
box.appendChild(close);
const wrap = document.createElement('div');
wrap.className = 'skills-panel';
wrap.innerHTML = `<h3>🧩 Skills de Claude Code</h3>
<p class="muted">Instala instrucciones especializadas (formato SKILL.md) desde repos públicos.
Todo se guarda en tu navegador. Se ve el repo y lo que se inyecta.</p>`;
box.appendChild(wrap);
// instaladas
const inst = skills.installed();
const instBox = document.createElement('div');
instBox.innerHTML = `<div class="sk-h">Instaladas (${inst.length})</div>`;
for (const s of inst) {
const row = document.createElement('div');
row.className = 'sk-row';
row.innerHTML = `<b>${s.name}</b><span class="muted">${s.repo || 'local'}</span>`;
const rm = document.createElement('button');
rm.className = 'ghost'; rm.textContent = 'Quitar';
rm.onclick = async () => { await skills.remove(s.name); openSkillsPanel(); };
row.appendChild(rm);
instBox.appendChild(row);
}
if (!inst.length) instBox.innerHTML += '<p class="muted">Ninguna todavía.</p>';
box.appendChild(instBox);
// fuentes + añadir repo
const srcs = await skills.sources();
const srcBox = document.createElement('div');
srcBox.innerHTML = `<div class="sk-h">Catálogos (repos)</div>`;
for (const s of srcs) {
const row = document.createElement('div');
row.className = 'sk-row';
row.innerHTML = `<b>${s.label}</b><a href="https://github.com/${s.repo}" target="_blank" rel="noopener">${s.repo} ↗</a>`;
const browse = document.createElement('button');
browse.className = 'primary'; browse.textContent = 'Explorar';
browse.onclick = () => browseRepo(s.repo, box);
row.appendChild(browse);
if (!s.official) {
const rm = document.createElement('button');
rm.className = 'ghost'; rm.textContent = '🗑';
rm.onclick = async () => { await skills.removeSource(s.repo); openSkillsPanel(); };
row.appendChild(rm);
}
srcBox.appendChild(row);
}
const addRow = document.createElement('div');
addRow.className = 'sk-row';
const inp = document.createElement('input');
inp.placeholder = 'owner/repo o URL de GitHub (p. ej. OpenClaude/…)';
const add = document.createElement('button');
add.className = 'primary'; add.textContent = 'Añadir repo';
add.onclick = async () => {
try { await skills.addSource(inp.value); openSkillsPanel(); }
catch (e) { alert(e.message); }
};
addRow.append(inp, add);
srcBox.appendChild(addRow);
box.appendChild(srcBox);
}
async function browseRepo(repo, box) {
const list = document.createElement('div');
list.innerHTML = `<div class="sk-h">Cargando ${repo}…</div>`;
box.appendChild(list);
try {
const found = await skills.listFromRepo(repo);
list.innerHTML = `<div class="sk-h">${repo}${found.length} skills</div>`;
for (const sk of found.slice(0, 120)) {
const row = document.createElement('div');
row.className = 'sk-row';
row.innerHTML = `<b>${sk.name}</b><span class="muted">${sk.dir}</span>`;
const btn = document.createElement('button');
const on = skills.isInstalled(sk.repo, sk.path);
btn.className = on ? 'ghost' : 'primary';
btn.textContent = on ? 'Instalada ✓' : 'Instalar';
btn.onclick = async () => {
btn.textContent = '…';
try {
const entry = await skills.installFromRepo(sk);
btn.textContent = 'Instalada ✓'; btn.className = 'ghost';
$('settings-panel').hidden = true; // cierra el panel
addMsg('assistant', skills.usageMessage(entry)); // «cómo usarla» en el chat
} catch (e) { btn.textContent = 'Instalar'; alert(e.message); }
};
row.appendChild(btn);
list.appendChild(row);
}
} catch (e) {
list.innerHTML = `<div class="sk-h">⚠️ ${e.message}</div>`;
}
}
// ---------- command palette (Cmd/Ctrl+P) ----------
let palItems = [], palSel = 0;
const COMMANDS = [
{ label: t('newConv'), run: () => conv.create() },
{ label: t('tHistory'), run: () => $('btn-history').click() },
{ label: t('pSearchCode'), run: () => { closePalette(); $('prompt').value = t('pSearch'); $('prompt').focus(); } },
{ label: t('pManageSkills'), run: () => { closePalette(); openSkillsPanel(); } },
{ label: t('tActSettings'), run: () => { closePalette(); renderSettings(); } },
{ label: t('pToggleExplorer'), run: () => $('act-files').click() },
{ label: t('pToggleTerm'), hint: 'Ctrl+`', run: () => { closePalette(); toggleTerminal(); } },
{ label: t('pSave'), run: () => triggerEditor('') || (hasEditor() && triggerEditor('workbench.action.files.save')) },
];
function openPalette(prefix = '') {
$('palette').hidden = false;
const inp = $('pal-input');
inp.value = prefix;
inp.focus();
renderPalette();
}
function closePalette() { $('palette').hidden = true; }
function fuzzy(q, s) {
q = q.toLowerCase(); s = s.toLowerCase();
let i = 0, score = 0, streak = 0;
for (const ch of s) {
if (i < q.length && ch === q[i]) { i++; streak++; score += streak; }
else streak = 0;
}
return i === q.length ? score + (s.endsWith(q) ? 20 : 0) : -1;
}
async function renderPalette() {
const raw = $('pal-input').value;
const list = $('pal-list');
list.replaceChildren();
palItems = [];
if (raw.startsWith('>')) { // comandos
const q = raw.slice(1).trim().toLowerCase();
palItems = COMMANDS.filter(c => c.label.toLowerCase().includes(q)).map(c => ({ label: c.label, hint: t('palCmd'), run: c.run }));
} else if (raw.startsWith('@')) { // símbolos del archivo abierto (Monaco)
palItems = [{ label: t('palGotoSym'), hint: '@', run: () => { closePalette(); triggerEditor('editor.action.quickOutline'); } }];
} else { // archivos (con :línea opcional)
const [pathQ, lineQ] = raw.split(':');
const files = await codeTools.fileList().catch(() => []);
const scored = pathQ.trim()
? files.map(f => ({ f, s: fuzzy(pathQ.trim(), f.split('/').pop()) * 2 + fuzzy(pathQ.trim(), f) })).filter(x => x.s > 0).sort((a, b) => b.s - a.s)
: files.slice(0, 40).map(f => ({ f, s: 0 }));
palItems = scored.slice(0, 40).map(({ f }) => ({
label: f.split('/').pop(), hint: f, icon: true,
run: () => { closePalette(); openFile(f); if (lineQ && +lineQ) setTimeout(() => gotoLine(+lineQ), 300); },
}));
}
palSel = 0;
palItems.forEach((it, i) => {
const row = el('button', 'pal-item' + (i === 0 ? ' sel' : ''));
row.innerHTML = `<span class="pi-name">${it.label}</span><span class="pi-hint">${it.hint || ''}</span>`;
row.onmouseenter = () => { palSel = i; paintPalSel(); };
row.onclick = () => it.run();
list.appendChild(row);
});
if (!palItems.length) list.appendChild(el('div', 'pal-empty', t('palEmpty')));
}
function paintPalSel() {
[...$('pal-list').children].forEach((c, i) => c.classList.toggle('sel', i === palSel));
$('pal-list').children[palSel]?.scrollIntoView({ block: 'nearest' });
}
$('pal-input').addEventListener('input', renderPalette);
$('pal-input').addEventListener('keydown', e => {
if (e.key === 'Escape') return closePalette();
if (e.key === 'ArrowDown') { e.preventDefault(); palSel = Math.min(palSel + 1, palItems.length - 1); paintPalSel(); }
if (e.key === 'ArrowUp') { e.preventDefault(); palSel = Math.max(palSel - 1, 0); paintPalSel(); }
if (e.key === 'Enter') { e.preventDefault(); palItems[palSel]?.run(); }
});
document.addEventListener('keydown', e => {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'p') { e.preventDefault(); openPalette(e.shiftKey ? '>' : ''); }
if (e.key === 'Escape' && !$('palette').hidden) closePalette();
});
document.addEventListener('click', e => { if (!e.target.closest('#palette')) closePalette(); });
// ---------- barra de menú File/Edit/View/Git ----------
async function menuFor(which) {
if (which === 'file') return [
{ label: t('mNewFile'), run: () => { const p = prompt(t('promptNewPath')); if (p) codeTools.write({ path: p, content: '' }).then(() => { openFile(p); refreshTree(); }); } },
{ label: t('mSave'), hint: 'Ctrl+S', run: () => triggerEditor('workbench.action.files.save') },
{ label: t('mOpenFolder'), run: async () => { try { await enterIDE(await window.showDirectoryPicker({ mode: 'readwrite' })); } catch {} } },
];
if (which === 'edit') return [
{ label: t('mSearchCode'), run: () => { $('prompt').value = t('pSearch'); $('prompt').focus(); } },
{ label: t('mGotoFile'), hint: 'Cmd+P', run: () => openPalette('') },
{ label: t('mGotoSym'), run: () => triggerEditor('editor.action.quickOutline') },
];
if (which === 'view') return [
{ label: t('tFiles'), run: () => $('act-files').click() },
{ label: t('mTerminal'), hint: 'Ctrl+`', run: () => toggleTerminal(true) },
{ label: t('mArch'), run: () => openView('arch') },
{ label: t('tCity'), run: () => openView('city') },
{ label: 'Command Palette', hint: 'Cmd+Shift+P', run: () => openPalette('>') },
{ label: 'Skills', run: () => openSkillsPanel() },
{ label: t('mAbout'), hint: '↗', run: () => window.open('https://github.com/KikoCis/elffuss-code', '_blank', 'noopener') },
];
if (which === 'git') {
const g = await codeTools.gitInfo();
if (!g.isRepo) return [{ label: t('gNotRepo'), hint: '', run: () => {} }];
const items = [{ label: t('gBranch', { b: g.branch }), hint: 'git', run: () => {} }];
if (g.lastCommit) items.push({ label: t('gLast', { m: g.lastCommit.msg.slice(0, 40) }), hint: g.lastCommit.author, run: () => {} });
items.push({ label: t('gCommit'), run: () => { $('prompt').value = t('pCommit'); $('prompt').focus(); } });
return items;
}
return [];
}
document.querySelectorAll('#menubar button').forEach(b => {
b.addEventListener('click', async () => {
document.querySelectorAll('#menubar button').forEach(x => x.classList.toggle('open', x === b));
openMenuAt(await menuFor(b.dataset.menu), b);
});
});
function openMenuAt(items, anchor) {
const menu = $('topmenu');
const r = anchor.getBoundingClientRect();
menu.style.left = r.left + 'px';
menu.style.top = (r.bottom + 3) + 'px';
menu.replaceChildren();
for (const it of items) {
const row = el('button', 'menu-item');
row.innerHTML = `<b>${it.label}</b>${it.hint ? `<span>${it.hint}</span>` : ''}`;
row.onclick = () => { menu.hidden = true; it.run(); };
menu.appendChild(row);
}
menu.hidden = false;
}
document.addEventListener('click', e => {
if (!e.target.closest('#menubar, #topmenu')) {
document.querySelectorAll('#menubar button').forEach(x => x.classList.remove('open'));
$('topmenu').hidden = true;
}
});
// ---------- git en el header ----------
async function refreshGit() {
const g = await codeTools.gitInfo().catch(() => ({ isRepo: false }));
const chip = $('git-branch');
if (g.isRepo) { chip.hidden = false; chip.innerHTML = UI.code + ' ' + g.branch; }
else chip.hidden = true;
}
$('git-branch').addEventListener('click', () => document.querySelector('#menubar button[data-menu="git"]').click());
// icono de skills en la barra de actividad
$('act-skills').innerHTML = UI.puzzle;
$('act-skills').addEventListener('click', () => openSkillsPanel());
rebuildSelect();
skills.initSkills();
boot();
window.elffussClaw = { conv, get agent() { return conv.getActive()?.agent; }, send, openFile, parseToolCall, skills };