lampson 0.2.6 → 0.2.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +16 -10
- package/bin/lampson.js +13 -7
- package/chat.syn +36 -31
- package/lampson.ps1 +11 -1
- package/lampson.sh +7 -0
- package/lib/agents.syn +10 -10
- package/lib/fs.syn +226 -0
- package/lib/loop.syn +9 -8
- package/lib/permission.syn +40 -14
- package/lib/plugins.syn +408 -0
- package/lib/prompt.syn +3 -2
- package/lib/sched_run.syn +10 -10
- package/lib/schedule.syn +36 -21
- package/lib/tools/fetch.syn +573 -0
- package/lib/tools/memo.syn +27 -5
- package/lib/tools/url.syn +220 -0
- package/lib/tools.syn +32 -30
- package/lib/workspaces.syn +34 -9
- package/package.json +12 -4
- package/{lamps/example-hello/lamp.json → plugins/example-hello/plugin.json} +2 -2
- package/plugins/example-hello/plugin.syn +19 -0
- package/public/css/panel.css +3 -3
- package/public/css/sidebar.css +21 -0
- package/public/hub.html +1 -1
- package/public/index.html +3 -3
- package/public/js/app.js +1 -1
- package/public/js/chat.js +4 -4
- package/public/js/core.js +1 -1
- package/public/js/memory.js +2 -1
- package/public/js/{lamps.js → plugins.js} +43 -41
- package/public/js/schedules.js +9 -9
- package/public/js/tree.js +212 -10
- package/public/js/workspaces.js +1 -1
- package/skills/lampson/SKILL.md +26 -11
- package/web.syn +32 -17
- package/lamps/example-hello/lamp.syn +0 -19
- package/lib/lamps.syn +0 -386
package/public/js/chat.js
CHANGED
|
@@ -69,13 +69,13 @@ function handle(chunk, thinking) {
|
|
|
69
69
|
const k = ev.kind, d = ev.data;
|
|
70
70
|
if (k === 'session') { const changed = d !== session; session = d; localStorage.setItem('lampson.session', d); if (changed) loadTodo(); }
|
|
71
71
|
else if (k === 'assistant') add('assistant', md(d));
|
|
72
|
-
else if (k === 'tool_call') { thinking.querySelector('span:last-child').textContent = 'ejecutando ' + esc(d.name) + '…'; pending = add('step', `<span class="ic">⚙</span>${cmdHtml(describe(d))}`); wireMore(pending); if (d.name === 'process') setTimeout(loadProcs, 2500); if (d.name === 'delegate') { setTimeout(loadAgents, 800); setTimeout(loadAgents, 4000); } if (d.name === 'todo') setTimeout(loadTodo, 300); if (d.name === '
|
|
72
|
+
else if (k === 'tool_call') { thinking.querySelector('span:last-child').textContent = 'ejecutando ' + esc(d.name) + '…'; pending = add('step', `<span class="ic">⚙</span>${cmdHtml(describe(d))}`); wireMore(pending); if (d.name === 'process') setTimeout(loadProcs, 2500); if (d.name === 'delegate') { setTimeout(loadAgents, 800); setTimeout(loadAgents, 4000); } if (d.name === 'todo') setTimeout(loadTodo, 300); if (d.name === 'plugin') setTimeout(loadPlugins, 300); if (d.name === 'lsp') setTimeout(loadLsp, 1500); }
|
|
73
73
|
else if (k === 'inbox') { add('meta', '✉ ' + esc(String(d).split('\n')[0].slice(0, 160))); loadAgents(); }
|
|
74
74
|
else if (k === 'tool_result') { const out = String(d.output); const bad = /^(ERROR|DENIED)/.test(out);
|
|
75
|
-
if (!bad && d.call && /^(write|edit|bash|process|delegate|
|
|
75
|
+
if (!bad && d.call && /^(write|edit|bash|process|delegate|plugin|skill)$/.test(d.call.name)) treeChanged();
|
|
76
76
|
if (d.call && d.call.name === 'schedule') { setTimeout(loadSched, 300); if (/^scheduled '/.test(out)) setSec('sched', true); }
|
|
77
|
-
//
|
|
78
|
-
if (d.call && d.call.name === '
|
|
77
|
+
// plugin recién creado: avisar en el chat con un acceso directo al switch (el modelo no sabe cómo se enciende en esta UI)
|
|
78
|
+
if (d.call && d.call.name === 'plugin' && /^plugin '([^']+)' created/.test(out)) { const nm = out.match(/^plugin '([^']+)' created/)[1]; const m = add('meta', `☼ plugin <b>${esc(nm)}</b> creado — está apagado: <a href="#" class="plugingo">encenderlo en «plugins»</a>`); m.querySelector('.plugingo').onclick = (ev) => { ev.preventDefault(); openPlugins(nm); }; $('#plugins').classList.add('new'); setTimeout(() => $('#plugins').classList.remove('new'), 4000); }
|
|
79
79
|
const el = pending || add('step', '<span class="ic">→</span>'); pending = null; thinking.querySelector('span:last-child').textContent = 'pensando…'; const ic = el.querySelector('.ic'); if (ic) { ic.textContent = bad ? '✗' : '✓'; ic.className = 'ic ' + (bad ? 'bad' : 'ok'); } el.insertAdjacentHTML('beforeend', resultHtml(out, bad)); }
|
|
80
80
|
else if (k === 'approval_request') { const el = add('approval', `<div class="card"><div class="why">⚠ ${esc(d.why)}</div><code>${esc(describe({name: d.name, args: d.args}))}</code><div class="btns"><button class="primary" data-ok="1">Permitir</button><button data-ok="0">Denegar</button></div></div>`); el.querySelectorAll('button').forEach(b => b.onclick = async () => { el.querySelectorAll('button').forEach(x => x.disabled = true); await api(BASE + '/api/approve', { id: d.id, decision: b.dataset.ok === '1' }); }); el.dataset.id = d.id; }
|
|
81
81
|
else if (k === 'approval_result') { const el = [...log.querySelectorAll('.approval')].find(x => x.dataset.id === d.id); if (el) el.querySelector('.btns').innerHTML = d.approved ? '<span style="color:var(--str)">✓ permitido</span>' : (d.timeout ? '<span class="denied">✗ sin respuesta, denegado</span>' : '<span class="denied">✗ denegado</span>'); }
|
package/public/js/core.js
CHANGED
|
@@ -22,7 +22,7 @@ function md(src) { // Markdown mínimo: code fences, inline code, bold, headers,
|
|
|
22
22
|
}
|
|
23
23
|
// un bloque en el chat: 'user' | 'assistant' | 'step' | 'meta' | 'denied' | 'approval' | 'working'
|
|
24
24
|
function add(cls, html) { showPane('log'); log.classList.remove('hero'); const d = document.createElement('div'); d.className = 'msg ' + cls; d.innerHTML = html; log.appendChild(d); $('#stage').scrollTop = $('#stage').scrollHeight; return d; }
|
|
25
|
-
function describe(c) { const a = c.args || {}; if (c.name === 'bash') return '$ ' + (a.command || ''); if (c.name === 'delegate') return 'delegate → ' + a.agent + ': ' + String(a.brief || '').slice(0, 80); if (a.path) return c.name + ' ' + a.path; if (a.pattern) return c.name + ' ' + a.pattern; if (a.name) return c.name + ' ' + a.name; return c.name + ' ' + JSON.stringify(a); }
|
|
25
|
+
function describe(c) { const a = c.args || {}; if (c.name === 'bash') return '$ ' + (a.command || ''); if (c.name === 'fetch') return 'fetch ' + (a.url || '') + (a.format && a.format !== 'markdown' ? ' (' + a.format + ')' : ''); if (c.name === 'delegate') return 'delegate → ' + a.agent + ': ' + String(a.brief || '').slice(0, 80); if (a.path) return c.name + ' ' + a.path; if (a.pattern) return c.name + ' ' + a.pattern; if (a.name) return c.name + ' ' + a.name; return c.name + ' ' + JSON.stringify(a); }
|
|
26
26
|
function cmdHtml(text) { const t = String(text); const long = t.length > 220 || t.split('\n').length > 4; return `<span class="cmd">${esc(t)}</span>` + (long ? ' <span class="more">ver todo</span>' : ''); }
|
|
27
27
|
function wireMore(el) { const m = el.querySelector('.more'); if (m) m.onclick = () => { el.querySelector('.cmd').classList.toggle('open'); m.textContent = el.querySelector('.cmd').classList.contains('open') ? 'menos' : 'ver todo'; }; }
|
|
28
28
|
// POST JSON → {ok, status, data}
|
package/public/js/memory.js
CHANGED
|
@@ -4,7 +4,8 @@ async function loadMemory() {
|
|
|
4
4
|
let r; try { r = await (await fetch(BASE + '/api/memory')).json(); } catch (e) { return; }
|
|
5
5
|
const box = $('#memory'); box.innerHTML = '';
|
|
6
6
|
const notes = r.notes || [];
|
|
7
|
-
$('#memCount').textContent = notes.length || ''; autoSec('memory', notes.length > 0);
|
|
7
|
+
$('#memCount').textContent = notes.length || ''; autoSec('memory', notes.length > 0 || r.ok === false);
|
|
8
|
+
if (r.ok === false) { box.innerHTML = '<div class="m none" title="el enlace memory/ del workspace no resuelve: volvé a correr lampson en este proyecto para repararlo">⚠ carpeta de memoria no accesible — corré <code>lampson</code> de nuevo en este proyecto</div>'; return; }
|
|
8
9
|
if (!notes.length) { box.innerHTML = '<div class="m none">sin notas todavía</div>'; return; }
|
|
9
10
|
for (const n of notes) { const d = document.createElement('div'); d.className = 'm' + (memOpen === n.name ? ' active' : ''); d.title = n.title; d.innerHTML = `<b>${esc(n.name)}</b><span class="t">${esc(n.title)}</span>`; d.onclick = () => openMemory(n.name); box.appendChild(d); }
|
|
10
11
|
}
|
|
@@ -1,20 +1,20 @@
|
|
|
1
|
-
//
|
|
1
|
+
// plugins.js — plugins (tools propias): pill en la cabecera + vista browse del Panel (lista, detalle con
|
|
2
2
|
// switch, tools con formulario generado del schema, correr, borrar) y la ayuda «?».
|
|
3
|
-
let
|
|
4
|
-
async function
|
|
5
|
-
let r; try { r = await (await fetch(BASE + '/api/
|
|
6
|
-
|
|
7
|
-
const on =
|
|
8
|
-
const pill = $('#
|
|
9
|
-
return
|
|
3
|
+
let pluginsData = [], pluginsMeta = { global: 'plugins/', project: '.lampson/plugins/' };
|
|
4
|
+
async function fetchPlugins() {
|
|
5
|
+
let r; try { r = await (await fetch(BASE + '/api/plugins')).json(); } catch (e) { return pluginsData; }
|
|
6
|
+
pluginsData = r.plugins || []; pluginsMeta = { global: r.global || 'plugins/', project: r.project || '.lampson/plugins/' };
|
|
7
|
+
const on = pluginsData.filter(l => l.enabled).length;
|
|
8
|
+
const pill = $('#plugins'); pill.textContent = pluginsData.length ? `plugins ${on}/${pluginsData.length}` : 'plugins'; pill.classList.toggle('on', on > 0);
|
|
9
|
+
return pluginsData;
|
|
10
10
|
}
|
|
11
|
-
async function
|
|
12
|
-
function
|
|
11
|
+
async function loadPlugins() { await fetchPlugins(); if (Panel.is('plugins')) Panel.refresh(); }
|
|
12
|
+
function pluginMatches(l, q) {
|
|
13
13
|
if (!q) return true;
|
|
14
14
|
const hay = [l.name, l.description || '', l.scope, l.kind, ...(l.tools || [])].join(' ').toLowerCase();
|
|
15
15
|
return q.toLowerCase().split(/\s+/).every(t => hay.includes(t));
|
|
16
16
|
}
|
|
17
|
-
function
|
|
17
|
+
function pluginField(k, p, required) {
|
|
18
18
|
const def = p.default !== undefined ? p.default : ''; const req = required.includes(k) ? ' <span class="req">(obligatorio)</span>' : '';
|
|
19
19
|
let input;
|
|
20
20
|
if (Array.isArray(p.enum)) input = `<select data-k="${esc(k)}">${p.enum.map(v => `<option value="${esc(String(v))}" ${String(v) === String(def) ? 'selected' : ''}>${esc(String(v))}</option>`).join('')}</select>`;
|
|
@@ -24,7 +24,7 @@ function lampField(k, p, required) {
|
|
|
24
24
|
return `<label class="lf"><span class="lk">${esc(k)}${req}</span>${input}${p.description ? `<span class="ds">${esc(p.description)}</span>` : ''}</label>`;
|
|
25
25
|
}
|
|
26
26
|
// lee los campos de una tool → args (o {error})
|
|
27
|
-
function
|
|
27
|
+
function pluginArgs(tdiv, props, required) {
|
|
28
28
|
const args = {};
|
|
29
29
|
for (const el of tdiv.querySelectorAll('[data-k]')) {
|
|
30
30
|
const k = el.dataset.k, p = props[k] || {}; let v = el.value;
|
|
@@ -36,10 +36,10 @@ function lampArgs(tdiv, props, required) {
|
|
|
36
36
|
}
|
|
37
37
|
return { args };
|
|
38
38
|
}
|
|
39
|
-
const
|
|
40
|
-
<p>
|
|
41
|
-
<p><b>
|
|
42
|
-
<p><b>
|
|
39
|
+
const PLUGINS_HELP = `<h4>Herramientas hechas a medida para este proyecto</h4>
|
|
40
|
+
<p>Un plugin es una tool que el agente (o vos) escribe para <em>este</em> repo: una carpeta con <code>plugin.json</code> y el código — en Synsema (corre bajo un techo de capacidades que no puede superar) o en cualquier lenguaje (<code>python</code>, <code>node</code>, un binario). A diferencia de un script suelto, el modelo lo ve en su catálogo con parámetros tipados, vos lo podés correr desde acá, y queda en el repo para la próxima sesión.</p>
|
|
41
|
+
<p><b>Pedilo en el chat</b> — <i>«hacé un plugin que liste los endpoints del backend»</i>. El agente lo escribe en <code data-project></code>, lo valida y te pide encenderlo acá.</p>
|
|
42
|
+
<p><b>Encendido = existe</b> — apagado, el agente ni lo ve. Al encenderlo, sus tools entran al catálogo como <code>plugin_<plugin>_<tool></code> desde el próximo turno. Los globales viven en <code data-global></code> y valen para todos los proyectos.</p>
|
|
43
43
|
<p><b>Ejemplos</b></p>
|
|
44
44
|
<ul>
|
|
45
45
|
<li><b>Consultas del proyecto</b> — <code>endpoints()</code> que parsea tus rutas y devuelve método + path + handler; <code>env_keys()</code> usadas vs. definidas en <code>.env.example</code>.</li>
|
|
@@ -48,51 +48,53 @@ const LAMPS_HELP = `<h4>Herramientas hechas a medida para este proyecto</h4>
|
|
|
48
48
|
<li><b>Datos locales</b> — <code>db_schema()</code> / <code>query(sql)</code> contra tu SQLite con techo <code>db=./dev.db</code>.</li>
|
|
49
49
|
<li><b>Integraciones sin MCP</b> — <code>deploy_status()</code> contra tu API con techo <code>net=api.x.com</code>.</li>
|
|
50
50
|
</ul>
|
|
51
|
-
<p
|
|
52
|
-
|
|
51
|
+
<p>¿Querés herramientas con techo real, versionadas y compartidas entre agentes (Claude Code, Cursor, lampson…)? Eso son las <a href="https://lamps.sh" target="_blank" rel="noopener">lámparas de lamps.sh</a>: se instalan con <code>lamp add</code> y entran acá como servidor MCP (<code>lamp mcp</code>).</p>
|
|
52
|
+
<p><button class="primary" data-back>← volver a los plugins</button></p>`;
|
|
53
|
+
function openPlugins(selectName) {
|
|
53
54
|
let first = selectName || null;
|
|
54
55
|
Panel.open({
|
|
55
|
-
id: '
|
|
56
|
-
headActions: [{ label: '?', title: '¿qué es
|
|
56
|
+
id: 'plugins', eyebrow: 'plugins', title: 'Plugins', layout: 'browse',
|
|
57
|
+
headActions: [{ label: '?', title: '¿qué es un plugin?', onClick: (b) => { Panel.help(true); const h = b.querySelector('.phelp'); h.querySelector('[data-project]').textContent = pluginsMeta.project; h.querySelector('[data-global]').textContent = pluginsMeta.global; h.querySelector('[data-back]').onclick = () => Panel.help(false); } }],
|
|
57
58
|
browse: {
|
|
58
|
-
placeholder: 'buscar
|
|
59
|
+
placeholder: 'buscar plugin o tool…', listWidth: '260px', help: PLUGINS_HELP,
|
|
59
60
|
key: l => l.name,
|
|
60
|
-
load: async (q) => { const all = await
|
|
61
|
-
render: (l) => `<span class="dot">${l.error ? '!' : (l.enabled ? '●' : '○')}</span><div><div class="nm">${esc(l.name)}</div><div class="meta">${esc(l.scope)} · ${esc(l.kind)} · ${(l.tools || []).length} tool${(l.tools || []).length === 1 ? '' : 's'}${l.error ? ' ·
|
|
62
|
-
count: (rows, q) => q ? `${rows.length} de ${
|
|
63
|
-
emptyHtml: '
|
|
64
|
-
emptyDetail: `Todavía no hay
|
|
61
|
+
load: async (q) => { const all = await fetchPlugins(); const list = all.filter(l => pluginMatches(l, q)); if (first) { const i = list.findIndex(l => l.name === first); first = null; if (i > 0) list.unshift(list.splice(i, 1)[0]); } return list; },
|
|
62
|
+
render: (l) => `<span class="dot">${l.error ? '!' : (l.enabled ? '●' : '○')}</span><div><div class="nm">${esc(l.name)}</div><div class="meta">${esc(l.scope)} · ${esc(l.kind)} · ${(l.tools || []).length} tool${(l.tools || []).length === 1 ? '' : 's'}${l.error ? ' · roto' : ''}${l.legacy ? ' · carpeta vieja' : ''}</div></div>`,
|
|
63
|
+
count: (rows, q) => q ? `${rows.length} de ${pluginsData.length}` : `${pluginsData.length} plugin${pluginsData.length === 1 ? '' : 's'}`,
|
|
64
|
+
emptyHtml: 'ninguno todavía',
|
|
65
|
+
emptyDetail: `Todavía no hay plugins.<br><br>Pedile uno al agente en el chat — <i>«hacé un plugin que liste los endpoints del backend»</i> — o creá una carpeta con <code>plugin.json</code> en <code>${esc(pluginsMeta.project)}</code> (proyecto) o <code>${esc(pluginsMeta.global)}</code> (global). El botón <b>?</b> de arriba explica qué son y para qué sirven.`,
|
|
65
66
|
detail: (l) => {
|
|
66
67
|
const specs = l.tool_specs || [];
|
|
67
|
-
const cap = l.error ? `<div class="dcap err">
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
+ (l.
|
|
68
|
+
const cap = l.error ? `<div class="dcap err">roto: ${esc(l.error)}</div>` : (l.kind === 'syn' ? `<div class="dcap">techo de capacidades: ${esc(l.caps)}</div>` : `<div class="dcap"><b>sin techo</b> (exec) · $ ${esc(l.command)}</div>`);
|
|
69
|
+
const legacy = l.legacy ? `<div class="dnote">Carpeta vieja: vive en <code>.lampson/lamps/</code>. Sigue funcionando, pero renombrala a <code>.lampson/plugins/</code> (y <code>lamp.json</code> a <code>plugin.json</code>); las variables <code>LAMP_*</code> siguen llegando por ahora.</div>` : '';
|
|
70
|
+
return `<div class="dhead"><span class="nm">${esc(l.name)}</span><span class="meta">${esc(l.scope)} · ${esc(l.kind)}</span><label class="sw ${l.enabled ? 'on' : ''}" title="${l.error ? 'no se puede encender: está roto' : (l.enabled ? 'apagar' : 'encender')}"><input type="checkbox" ${l.enabled ? 'checked' : ''} ${l.error ? 'disabled' : ''}>${l.enabled ? 'encendido' : 'apagado'}</label></div>`
|
|
71
|
+
+ (l.description ? `<div class="ddesc">${esc(l.description)}</div>` : '') + cap + legacy
|
|
72
|
+
+ (l.enabled || l.error ? '' : `<div class="dnote">Apagado: el agente no lo ve y no se puede correr. Encendelo con el switch — el código va a poder ejecutarse${l.kind === 'exec' ? ' <b>sin techo de capacidades</b> (es un ejecutable)' : ' bajo el techo de arriba'}.</div>`)
|
|
71
73
|
+ specs.map((t, i) => {
|
|
72
74
|
const props = (t.parameters && t.parameters.properties) || {}; const required = (t.parameters && t.parameters.required) || [];
|
|
73
|
-
return `<div class="tool" data-i="${i}"><div class="th"><code>${esc(t.name)}</code>${t.description ? `<span class="ds">${esc(t.description)}</span>` : '<span></span>'}<span class="ds">
|
|
74
|
-
+ (Object.keys(props).length ? Object.keys(props).map(k =>
|
|
75
|
+
return `<div class="tool" data-i="${i}"><div class="th"><code>${esc(t.name)}</code>${t.description ? `<span class="ds">${esc(t.description)}</span>` : '<span></span>'}<span class="ds">plugin_${esc(l.name)}_${esc(t.name)}</span></div>`
|
|
76
|
+
+ (Object.keys(props).length ? Object.keys(props).map(k => pluginField(k, props[k], required)).join('') : '<div class="ds" style="margin-top:6px;color:var(--ink-3)">no toma parámetros</div>')
|
|
75
77
|
+ (l.enabled ? `<div class="go"><button class="primary run">▶ correr</button><span class="st"></span></div><pre class="out" style="display:none"></pre>` : '')
|
|
76
78
|
+ `</div>`;
|
|
77
79
|
}).join('')
|
|
78
|
-
+ `<div class="dfoot"><span>${esc(l.dir || '')}</span>${l.scope === 'project' ? '<span class="del">eliminar</span>' : '<span title="
|
|
80
|
+
+ `<div class="dfoot"><span>${esc(l.dir || '')}</span>${l.scope === 'project' ? '<span class="del">eliminar</span>' : '<span title="los globales se borran a mano">global · se borra a mano</span>'}</div><div class="err" data-err></div>`;
|
|
79
81
|
},
|
|
80
82
|
wire: (l, box) => {
|
|
81
83
|
const errEl = box.querySelector('[data-err]');
|
|
82
84
|
box.querySelector('.sw input').onchange = async (ev) => {
|
|
83
85
|
errEl.textContent = '';
|
|
84
|
-
const r = await api(BASE + '/api/
|
|
86
|
+
const r = await api(BASE + '/api/plugins/toggle', { name: l.name, enabled: ev.target.checked });
|
|
85
87
|
if (!r.ok) { errEl.textContent = r.data.error || ('error ' + r.status); ev.target.checked = !ev.target.checked; return; }
|
|
86
|
-
add('meta', '☼ ' + esc(r.data.result || ''));
|
|
88
|
+
add('meta', '☼ ' + esc(r.data.result || '')); loadPlugins(); loadSched();
|
|
87
89
|
};
|
|
88
90
|
for (const tdiv of box.querySelectorAll('.tool')) {
|
|
89
91
|
const t = (l.tool_specs || [])[Number(tdiv.dataset.i)]; const props = (t.parameters && t.parameters.properties) || {}; const required = (t.parameters && t.parameters.required) || [];
|
|
90
92
|
const b = tdiv.querySelector('button.run'); if (!b) continue;
|
|
91
93
|
b.onclick = async () => {
|
|
92
|
-
const st = tdiv.querySelector('.st'), out = tdiv.querySelector('.out'); const got =
|
|
94
|
+
const st = tdiv.querySelector('.st'), out = tdiv.querySelector('.out'); const got = pluginArgs(tdiv, props, required);
|
|
93
95
|
if (got.error) { st.textContent = got.error; return; }
|
|
94
96
|
st.textContent = 'corriendo…'; out.style.display = 'none'; b.disabled = true;
|
|
95
|
-
const r = await api(BASE + '/api/
|
|
97
|
+
const r = await api(BASE + '/api/plugins/run', { tool: 'plugin_' + l.name + '_' + t.name, args: got.args });
|
|
96
98
|
st.textContent = r.ok ? '' : (r.data.error || 'error ' + r.status);
|
|
97
99
|
if (r.ok) { out.textContent = r.data.output; out.style.display = ''; }
|
|
98
100
|
b.disabled = false;
|
|
@@ -100,13 +102,13 @@ function openLamps(selectName) {
|
|
|
100
102
|
}
|
|
101
103
|
const del = box.querySelector('.dfoot .del');
|
|
102
104
|
if (del) del.onclick = () => inlineConfirm(del, `¿borrar ${l.name} del disco?`, async () => {
|
|
103
|
-
const r = await api(BASE + '/api/
|
|
105
|
+
const r = await api(BASE + '/api/plugins/remove', { name: l.name });
|
|
104
106
|
if (!r.ok) { errEl.textContent = r.data.error || ('error ' + r.status); return; }
|
|
105
|
-
add('meta', '☼ ' + esc(r.data.result || ''));
|
|
107
|
+
add('meta', '☼ ' + esc(r.data.result || '')); loadPlugins();
|
|
106
108
|
});
|
|
107
109
|
}
|
|
108
110
|
}
|
|
109
111
|
});
|
|
110
112
|
}
|
|
111
|
-
$('#
|
|
112
|
-
if (location.hash === '#lamps') setTimeout(() =>
|
|
113
|
+
$('#plugins').onclick = () => openPlugins();
|
|
114
|
+
if (location.hash === '#plugins' || location.hash === '#lamps') setTimeout(() => openPlugins(), 400); // deep link: abre el panel al cargar
|
package/public/js/schedules.js
CHANGED
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
// (tareas a la izquierda; a la derecha: qué hace, corridas, log, traza en vivo mientras corre, ▶ / ⏻ / quitar;
|
|
3
3
|
// «+ programar» es una fila más cuyo detalle es el formulario). Corren en cualquier lampson abierto (web o
|
|
4
4
|
// terminal, tick cada 30 s) o con `lampson --daemon start` con todo cerrado.
|
|
5
|
-
let schedData = [],
|
|
5
|
+
let schedData = [], schedPlugins = [], schedTick = 30, schedMountOk = true, schedLiveTimer = null;
|
|
6
6
|
async function fetchSched() {
|
|
7
7
|
let r; try { r = await (await fetch(BASE + '/api/schedules')).json(); } catch (e) { return schedData; }
|
|
8
|
-
schedData = r.tasks || [];
|
|
8
|
+
schedData = r.tasks || []; schedPlugins = r.plugins || []; schedTick = r.tick || 30; schedMountOk = r.mount_ok !== false;
|
|
9
9
|
return schedData;
|
|
10
10
|
}
|
|
11
11
|
async function loadSched() {
|
|
@@ -28,7 +28,7 @@ async function loadSched() {
|
|
|
28
28
|
box.appendChild(d);
|
|
29
29
|
}
|
|
30
30
|
const addRow = document.createElement('div'); addRow.className = 'p'; addRow.innerHTML = `<span class="nm" style="color:var(--accent)">+ programar una tarea…</span>`;
|
|
31
|
-
addRow.title = '
|
|
31
|
+
addRow.title = 'un plugin, un comando o el agente con instrucciones · también se lo podés pedir al agente en el chat';
|
|
32
32
|
addRow.onclick = () => openSched('__new');
|
|
33
33
|
box.appendChild(addRow);
|
|
34
34
|
if (Panel.is('schedules')) Panel.refresh();
|
|
@@ -42,7 +42,7 @@ function openSched(selectId) {
|
|
|
42
42
|
browse: {
|
|
43
43
|
placeholder: 'buscar tarea…', listWidth: '300px', key: t => t.id,
|
|
44
44
|
load: async (q) => { const all = await fetchSched(); const list = all.filter(t => !q || (t.name + ' ' + t.plan + ' ' + t.action_text).toLowerCase().includes(q.toLowerCase())); return q ? list : [...list, SCHED_NEW]; },
|
|
45
|
-
render: (t) => t === SCHED_NEW ? `<span class="dot">+</span><div><div class="nm" style="color:var(--accent);font-weight:400">programar una tarea…</div><div class="meta">
|
|
45
|
+
render: (t) => t === SCHED_NEW ? `<span class="dot">+</span><div><div class="nm" style="color:var(--accent);font-weight:400">programar una tarea…</div><div class="meta">plugin, comando o el agente</div></div>` : `<span class="dot">${schedStatusDot(t)}</span><div><div class="nm">${esc(t.name)}</div><div class="meta">${esc(t.plan)} · ${t.running ? 'corriendo' : (t.enabled ? 'próxima ' + esc(fmtWhen(t.next_run)) : 'apagada')}</div></div>`,
|
|
46
46
|
count: (rows) => { const n = rows.filter(r => r !== SCHED_NEW).length; return `${n} tarea${n === 1 ? '' : 's'}`; },
|
|
47
47
|
emptyHtml: 'nada coincide',
|
|
48
48
|
detail: async (t) => {
|
|
@@ -78,12 +78,12 @@ function openSched(selectId) {
|
|
|
78
78
|
});
|
|
79
79
|
}
|
|
80
80
|
function schedForm() {
|
|
81
|
-
const
|
|
81
|
+
const pluginOpts = schedPlugins.filter(l => l.enabled).flatMap(l => (l.tools || []).map(tl => `<option value="${esc(l.name + '/' + tl)}">${esc(l.name)} · ${esc(tl)}</option>`)).join('') || '<option value="">ningún plugin encendido</option>';
|
|
82
82
|
return `<div class="dhead"><span class="nm serif">Programar una tarea</span></div><div class="dform">
|
|
83
83
|
<p class="lead">Corre sola, a la hora que digas, mientras lampson esté abierto (esta web o la terminal); con todo cerrado, <code>lampson --daemon start</code>. También podés pedírsela al agente en el chat: <i>«todos los días a las 9 corré los tests y avisame»</i>.</p>
|
|
84
84
|
<label>nombre <input name="name" spellcheck="false" autocomplete="off" placeholder="tests nocturnos"></label>
|
|
85
85
|
<label>cuándo <input name="when" spellcheck="false" autocomplete="off" placeholder="daily 09:00 · every 6h · mon,wed 08:30 · today 15:14 · once 2026-09-01 10:00 · in 2h"></label>
|
|
86
|
-
<label>qué corre <select name="kind"><option value="prompt">el agente, con estas instrucciones</option><option value="bash">un comando de shell</option><option value="
|
|
86
|
+
<label>qué corre <select name="kind"><option value="prompt">el agente, con estas instrucciones</option><option value="bash">un comando de shell</option><option value="plugin">una tool de un plugin encendido</option></select></label>
|
|
87
87
|
<div data-kind="prompt">
|
|
88
88
|
<label class="col">instrucciones <textarea name="prompt" rows="4" spellcheck="false" placeholder="Corré la suite de tests. Si algo falla, buscá la causa y proponé el fix en el informe (no lo apliques). Terminá con un resumen corto."></textarea></label>
|
|
89
89
|
<label>perfil <select name="agent"><option value="build">build — puede editar y correr comandos</option><option value="review">review — corre tests, no edita</option><option value="plan">plan — solo lee y propone</option><option value="explore">explore — solo busca</option></select></label>
|
|
@@ -91,8 +91,8 @@ function schedForm() {
|
|
|
91
91
|
<p class="lead">Sobre de permisos de una corrida sin nadie mirando. En «preguntar», la aprobación aparece acá y, con URL pública + webhook (⚙), como link en tu canal; sin respuesta en 2 h se deniega. Lo destructivo del sistema se bloquea siempre.</p>
|
|
92
92
|
</div>
|
|
93
93
|
<div data-kind="bash" style="display:none"><label>comando <input name="command" spellcheck="false" autocomplete="off" placeholder="npm test"></label></div>
|
|
94
|
-
<div data-kind="
|
|
95
|
-
<label>
|
|
94
|
+
<div data-kind="plugin" style="display:none">
|
|
95
|
+
<label>plugin · tool <select name="plugintool">${pluginOpts}</select></label>
|
|
96
96
|
<label>args (JSON) <input name="args" spellcheck="false" autocomplete="off" placeholder='{"who": "cron"}'></label>
|
|
97
97
|
</div>
|
|
98
98
|
<label>avisar a <input name="notify" spellcheck="false" autocomplete="off" placeholder="opcional: URL de webhook que recibe el resultado (JSON)"></label>
|
|
@@ -106,7 +106,7 @@ function schedFormWire(box, err) {
|
|
|
106
106
|
const k = f('kind').value; let action;
|
|
107
107
|
if (k === 'prompt') action = { type: 'prompt', prompt: f('prompt').value.trim(), agent: f('agent').value };
|
|
108
108
|
else if (k === 'bash') action = { type: 'bash', command: f('command').value.trim() };
|
|
109
|
-
else { const v = f('
|
|
109
|
+
else { const v = f('plugintool').value; if (!v) { err('encendé un plugin primero'); return; } const [plugin, tool] = v.split('/'); let args = {}; if (f('args').value.trim()) { try { args = JSON.parse(f('args').value); } catch (e) { err('args: JSON inválido'); return; } } action = { type: 'plugin', plugin, tool, args }; }
|
|
110
110
|
const body = { name: f('name').value.trim(), when: f('when').value.trim(), action, permission: f('permission').value, notify: f('notify').value.trim() };
|
|
111
111
|
if (!body.when) { err('falta cuándo'); return; }
|
|
112
112
|
err('programando…');
|
package/public/js/tree.js
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
|
-
// tree.js — árbol de archivos (panel derecho) con estado git,
|
|
1
|
+
// tree.js — árbol de archivos (panel derecho) con estado git, el visor de archivos y el explorador:
|
|
2
|
+
// clic derecho = menú contextual (nuevo archivo/carpeta, abrir, renombrar, duplicar, copiar/cortar/pegar,
|
|
3
|
+
// copiar ruta relativa/absoluta, insertar la ruta en el chat, eliminar), arrastrar y soltar para mover
|
|
4
|
+
// (Ctrl al soltar = copiar; sobre un archivo = a su carpeta), F2 / Supr / Ctrl+C·X·V sobre la fila
|
|
5
|
+
// seleccionada. Las operaciones van a POST /api/fs (lib/fs.syn) y nunca pisan un destino existente.
|
|
6
|
+
// Las carpetas abiertas se recuerdan (localStorage lampson.tree.open) para que un cambio no las cierre.
|
|
2
7
|
let gitStatus = { changes: {} };
|
|
8
|
+
let treeRoot = ''; // ruta absoluta del workspace (para «copiar ruta absoluta»)
|
|
9
|
+
let treeSel = null; // {path, is_dir, row}: última fila clickeada (izquierdo o derecho)
|
|
10
|
+
let treeClip = null; // {path, is_dir, op: 'copy'|'cut'}
|
|
11
|
+
let treeDrag = null; // {path, is_dir} mientras se arrastra
|
|
12
|
+
const openDirs = new Set((() => { try { return JSON.parse(localStorage.getItem('lampson.tree.open') || '[]'); } catch (e) { return []; } })());
|
|
13
|
+
function saveOpen() { try { localStorage.setItem('lampson.tree.open', JSON.stringify([...openDirs])); } catch (e) {} }
|
|
14
|
+
|
|
3
15
|
async function loadGit() {
|
|
4
16
|
try { gitStatus = await (await fetch(BASE + '/api/git')).json(); } catch (e) { gitStatus = { repo: false, changes: {} }; }
|
|
5
17
|
const g = $('#git'); if (!gitStatus.repo) { g.style.display = 'none'; return; }
|
|
@@ -17,28 +29,218 @@ function gitClass(path, isDir) {
|
|
|
17
29
|
if (!isDir) { const c = ch[path]; return c === '??' ? 'gU' : c === 'A' ? 'gA' : c === 'D' ? 'gD' : c ? 'gM' : ''; }
|
|
18
30
|
const p = path + '/'; for (const k in ch) if (k.startsWith(p)) return 'gM'; return '';
|
|
19
31
|
}
|
|
32
|
+
function parentOf(p) { const i = p.lastIndexOf('/'); return i < 0 ? '.' : p.slice(0, i); }
|
|
33
|
+
function baseOf(p) { return p.slice(p.lastIndexOf('/') + 1); }
|
|
34
|
+
function cssEsc(s) { return (window.CSS && CSS.escape) ? CSS.escape(s) : String(s).replace(/["\\]/g, '\\$&'); }
|
|
35
|
+
function rowOf(path) { return $('#tree').querySelector(`.row[data-path="${cssEsc(path)}"]`); }
|
|
36
|
+
|
|
20
37
|
async function loadTree() {
|
|
21
38
|
await loadGit();
|
|
22
|
-
const box = $('#tree'); box.innerHTML = '';
|
|
39
|
+
const box = $('#tree'); box.innerHTML = ''; box.tabIndex = 0;
|
|
23
40
|
let t; try { t = await (await fetch(BASE + '/api/tree')).json(); } catch (e) { box.innerHTML = '<div class="row none">sin árbol</div>'; return; }
|
|
24
|
-
|
|
41
|
+
treeRoot = t.root || '';
|
|
42
|
+
const render = (entries, parent) => {
|
|
25
43
|
for (const e of entries) {
|
|
26
44
|
const n = document.createElement('div'); n.className = 'node';
|
|
27
45
|
const gc = gitClass(e.path, e.is_dir); const code = (gitStatus.changes || {})[e.path];
|
|
28
|
-
const
|
|
29
|
-
r
|
|
46
|
+
const cut = treeClip && treeClip.op === 'cut' && treeClip.path === e.path;
|
|
47
|
+
const r = document.createElement('div'); r.className = 'row' + (e.is_dir ? ' dir' : '') + (gc ? ' ' + gc : '') + (cut ? ' cut' : '');
|
|
48
|
+
r.title = e.path + (code ? ' · git ' + code : ''); r.dataset.path = e.path; r.dataset.dir = e.is_dir ? '1' : '';
|
|
49
|
+
r.innerHTML = `<span class="tw">${e.is_dir ? '▸' : ''}</span><span class="nm">${esc(e.name)}${e.is_dir ? '/' : ''}</span>` + (code && !e.is_dir ? `<span class="gs">${code === '??' ? 'N' : code}</span>` : '');
|
|
30
50
|
n.appendChild(r);
|
|
51
|
+
wireRow(r, e);
|
|
31
52
|
if (e.is_dir) {
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
-
r.
|
|
35
|
-
|
|
53
|
+
const open = openDirs.has(e.path);
|
|
54
|
+
const k = document.createElement('div'); k.className = 'kids' + (open ? '' : ' hidden'); render(e.children || [], k); n.appendChild(k);
|
|
55
|
+
r.querySelector('.tw').textContent = open ? '▾' : '▸';
|
|
56
|
+
r.onclick = () => { select(r, e); toggleDir(r, k, e.path); };
|
|
57
|
+
} else { r.onclick = () => { select(r, e); openFile(e.path, r); }; }
|
|
36
58
|
parent.appendChild(n);
|
|
37
59
|
}
|
|
38
60
|
};
|
|
39
|
-
render(t.entries || [], box
|
|
61
|
+
render(t.entries || [], box);
|
|
40
62
|
if (t.truncated) { const d = document.createElement('div'); d.className = 'row none'; d.textContent = '… árbol truncado'; box.appendChild(d); }
|
|
63
|
+
if (treeSel) { const r = rowOf(treeSel.path); if (r) { treeSel.row = r; r.classList.add('sel'); } else treeSel = null; }
|
|
64
|
+
const vp = $('#vpath').textContent; const ar = vp && rowOf(vp); if (ar && $('#viewer').style.display !== 'none') ar.classList.add('active');
|
|
41
65
|
}
|
|
66
|
+
function toggleDir(r, k, path) {
|
|
67
|
+
k.classList.toggle('hidden'); const open = !k.classList.contains('hidden');
|
|
68
|
+
r.querySelector('.tw').textContent = open ? '▾' : '▸';
|
|
69
|
+
if (open) openDirs.add(path); else openDirs.delete(path); saveOpen();
|
|
70
|
+
}
|
|
71
|
+
function expandDir(path) { if (!path || path === '.') return; openDirs.add(path); saveOpen(); }
|
|
72
|
+
function select(r, e) {
|
|
73
|
+
document.querySelectorAll('#tree .row.sel').forEach(x => x.classList.remove('sel'));
|
|
74
|
+
r.classList.add('sel'); treeSel = { path: e.path, is_dir: e.is_dir, row: r };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ---- arrastrar y soltar: mover (Ctrl = copiar); sobre un archivo = a la carpeta del archivo ----
|
|
78
|
+
function canDrop(d, dir) {
|
|
79
|
+
if (!d) return false;
|
|
80
|
+
if (parentOf(d.path) === dir) return false; // ya está ahí
|
|
81
|
+
if (d.is_dir && (dir === d.path || dir.startsWith(d.path + '/'))) return false; // carpeta dentro de sí misma
|
|
82
|
+
return true;
|
|
83
|
+
}
|
|
84
|
+
function wireRow(r, e) {
|
|
85
|
+
r.oncontextmenu = ev => { ev.preventDefault(); ev.stopPropagation(); select(r, e); showMenu(ev.clientX, ev.clientY, { path: e.path, is_dir: e.is_dir, row: r }); };
|
|
86
|
+
r.draggable = true;
|
|
87
|
+
r.ondragstart = ev => { treeDrag = { path: e.path, is_dir: e.is_dir }; ev.dataTransfer.effectAllowed = 'copyMove'; ev.dataTransfer.setData('text/plain', e.path); r.classList.add('dragging'); };
|
|
88
|
+
r.ondragend = () => { treeDrag = null; r.classList.remove('dragging'); document.querySelectorAll('#tree .drop').forEach(x => x.classList.remove('drop')); };
|
|
89
|
+
// soltar sobre una carpeta = dentro de ella; sobre un archivo = en la carpeta del archivo (se ilumina esa)
|
|
90
|
+
const dir = e.is_dir ? e.path : parentOf(e.path);
|
|
91
|
+
const lit = () => (e.is_dir ? r : (rowOf(dir) || $('#tree')));
|
|
92
|
+
r.ondragover = ev => { if (!canDrop(treeDrag, dir)) return; ev.preventDefault(); ev.stopPropagation(); ev.dataTransfer.dropEffect = ev.ctrlKey ? 'copy' : 'move'; lit().classList.add('drop'); };
|
|
93
|
+
r.ondragleave = () => lit().classList.remove('drop');
|
|
94
|
+
r.ondrop = ev => { ev.preventDefault(); ev.stopPropagation(); document.querySelectorAll('#tree .drop, #tree.drop').forEach(x => x.classList.remove('drop')); dropInto(dir, ev.ctrlKey); };
|
|
95
|
+
}
|
|
96
|
+
async function dropInto(dir, copy) {
|
|
97
|
+
const d = treeDrag; treeDrag = null;
|
|
98
|
+
if (!canDrop(d, dir)) return;
|
|
99
|
+
await fsOp(copy ? { op: 'copy', path: d.path, to: dir } : { op: 'move', path: d.path, to: dir }, res => { expandDir(dir); if (!copy) afterMove(d.path, res.path); });
|
|
100
|
+
}
|
|
101
|
+
{
|
|
102
|
+
const box = $('#tree');
|
|
103
|
+
box.ondragover = ev => { if (!canDrop(treeDrag, '.')) return; ev.preventDefault(); ev.dataTransfer.dropEffect = ev.ctrlKey ? 'copy' : 'move'; box.classList.add('drop'); };
|
|
104
|
+
box.ondragleave = () => box.classList.remove('drop');
|
|
105
|
+
box.ondrop = ev => { ev.preventDefault(); box.classList.remove('drop'); dropInto('.', ev.ctrlKey); };
|
|
106
|
+
box.oncontextmenu = ev => { if (ev.target.closest('.row')) return; ev.preventDefault(); showMenu(ev.clientX, ev.clientY, null); };
|
|
107
|
+
box.onkeydown = ev => {
|
|
108
|
+
if (ev.target.tagName === 'INPUT' || !treeSel) return;
|
|
109
|
+
const s = treeSel; const key = ev.key.toLowerCase();
|
|
110
|
+
if (ev.key === 'F2') { ev.preventDefault(); renameInline(s); }
|
|
111
|
+
else if (ev.key === 'Delete') { ev.preventDefault(); const b = s.row.getBoundingClientRect(); showMenu(b.left + 60, b.bottom, s, 'delete'); }
|
|
112
|
+
else if (ev.ctrlKey && key === 'c') { ev.preventDefault(); setClip(s, 'copy'); }
|
|
113
|
+
else if (ev.ctrlKey && key === 'x') { ev.preventDefault(); setClip(s, 'cut'); }
|
|
114
|
+
else if (ev.ctrlKey && key === 'v') { ev.preventDefault(); pasteInto(s.is_dir ? s.path : parentOf(s.path)); }
|
|
115
|
+
else if (ev.key === 'Escape') { closeMenu(); }
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ---- operaciones (POST /api/fs) ----
|
|
120
|
+
async function fsOp(body, onOk) {
|
|
121
|
+
const r = await api(BASE + '/api/fs', body);
|
|
122
|
+
if (!r.ok) { add('denied', esc((r.data && r.data.error) || 'error')); loadTree(); return null; }
|
|
123
|
+
const res = r.data.result || {};
|
|
124
|
+
if (onOk) onOk(res);
|
|
125
|
+
await loadTree();
|
|
126
|
+
return res;
|
|
127
|
+
}
|
|
128
|
+
function afterMove(oldPath, newPath) {
|
|
129
|
+
if (treeClip && treeClip.path === oldPath) treeClip = null;
|
|
130
|
+
if (treeSel && treeSel.path === oldPath) treeSel.path = newPath;
|
|
131
|
+
const vp = $('#vpath'); const v = vp.textContent;
|
|
132
|
+
if (v === oldPath || v.startsWith(oldPath + '/')) vp.textContent = newPath + v.slice(oldPath.length);
|
|
133
|
+
}
|
|
134
|
+
function afterDelete(path) {
|
|
135
|
+
if (treeClip && treeClip.path === path) treeClip = null;
|
|
136
|
+
if (treeSel && (treeSel.path === path || treeSel.path.startsWith(path + '/'))) treeSel = null;
|
|
137
|
+
const v = $('#vpath').textContent; if (v === path || v.startsWith(path + '/')) { $('#vpath').textContent = ''; showPane('log'); }
|
|
138
|
+
}
|
|
139
|
+
function setClip(s, op) { treeClip = { path: s.path, is_dir: s.is_dir, op }; loadTree(); }
|
|
140
|
+
function canPaste(dir) {
|
|
141
|
+
if (!treeClip) return false;
|
|
142
|
+
if (treeClip.op === 'cut') return canDrop(treeClip, dir);
|
|
143
|
+
return !(treeClip.is_dir && (dir === treeClip.path || dir.startsWith(treeClip.path + '/')));
|
|
144
|
+
}
|
|
145
|
+
async function pasteInto(dir) {
|
|
146
|
+
if (!canPaste(dir)) return;
|
|
147
|
+
const c = treeClip;
|
|
148
|
+
await fsOp(c.op === 'cut' ? { op: 'move', path: c.path, to: dir } : { op: 'copy', path: c.path, to: dir }, res => { expandDir(dir); if (c.op === 'cut') { afterMove(c.path, res.path); treeClip = null; } });
|
|
149
|
+
}
|
|
150
|
+
function absPath(path) {
|
|
151
|
+
if (!treeRoot) return path;
|
|
152
|
+
const sep = treeRoot.includes('\\') ? '\\' : '/';
|
|
153
|
+
const root = treeRoot.replace(/[\\/]+$/, '');
|
|
154
|
+
return path === '.' ? root : root + sep + path.split('/').join(sep);
|
|
155
|
+
}
|
|
156
|
+
function copyText(t, row) {
|
|
157
|
+
const done = () => { if (row) { row.classList.add('flash'); setTimeout(() => row.classList.remove('flash'), 700); } };
|
|
158
|
+
if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(t).then(done, () => copyFallback(t, done)); else copyFallback(t, done);
|
|
159
|
+
}
|
|
160
|
+
function copyFallback(t, done) { const ta = document.createElement('textarea'); ta.value = t; ta.style.position = 'fixed'; ta.style.opacity = '0'; document.body.appendChild(ta); ta.select(); try { document.execCommand('copy'); } catch (e) {} ta.remove(); done(); }
|
|
161
|
+
function insertInChat(path) {
|
|
162
|
+
const ta = $('#in'); const v = ta.value;
|
|
163
|
+
ta.value = v + (v && !/\s$/.test(v) ? ' ' : '') + path + ' ';
|
|
164
|
+
ta.focus(); ta.dispatchEvent(new Event('input', { bubbles: true }));
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// ---- nuevo archivo / carpeta y renombrar: un input en la propia fila ----
|
|
168
|
+
function nameInput(r, value, onDone) {
|
|
169
|
+
const nm = r.querySelector('.nm'); const inp = document.createElement('input'); inp.className = 'rn'; inp.value = value; inp.spellcheck = false;
|
|
170
|
+
nm.replaceWith(inp); inp.focus();
|
|
171
|
+
const dot = value.startsWith('.') ? -1 : value.lastIndexOf('.'); inp.setSelectionRange(0, dot > 0 ? dot : value.length);
|
|
172
|
+
let finished = false;
|
|
173
|
+
const finish = ok => { if (finished) return; finished = true; onDone(ok ? inp.value.trim() : null); };
|
|
174
|
+
inp.onkeydown = ev => { ev.stopPropagation(); if (ev.key === 'Enter') { ev.preventDefault(); finish(true); } else if (ev.key === 'Escape') { ev.preventDefault(); finish(false); } };
|
|
175
|
+
inp.onblur = () => finish(false);
|
|
176
|
+
inp.onclick = ev => ev.stopPropagation(); inp.oncontextmenu = ev => ev.stopPropagation();
|
|
177
|
+
}
|
|
178
|
+
function newEntry(dir, isDir) {
|
|
179
|
+
closeMenu();
|
|
180
|
+
const box = $('#tree');
|
|
181
|
+
let holder = box;
|
|
182
|
+
if (dir !== '.') {
|
|
183
|
+
const r = rowOf(dir); if (!r) return;
|
|
184
|
+
const k = r.parentNode.querySelector('.kids'); if (k.classList.contains('hidden')) toggleDir(r, k, dir);
|
|
185
|
+
holder = k;
|
|
186
|
+
}
|
|
187
|
+
const n = document.createElement('div'); n.className = 'node';
|
|
188
|
+
const r = document.createElement('div'); r.className = 'row new' + (isDir ? ' dir' : ''); r.innerHTML = `<span class="tw">${isDir ? '▸' : ''}</span><span class="nm"></span>`;
|
|
189
|
+
n.appendChild(r); holder.insertBefore(n, holder.firstChild);
|
|
190
|
+
nameInput(r, '', name => {
|
|
191
|
+
n.remove();
|
|
192
|
+
if (!name) return;
|
|
193
|
+
fsOp({ op: isDir ? 'mkdir' : 'create', path: dir, name }, res => { expandDir(dir); treeSel = { path: res.path, is_dir: !!res.is_dir }; if (!isDir) openFile(res.path); });
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
function renameInline(s) {
|
|
197
|
+
closeMenu();
|
|
198
|
+
const r = s.row || rowOf(s.path); if (!r) return;
|
|
199
|
+
const old = baseOf(s.path);
|
|
200
|
+
nameInput(r, old, name => {
|
|
201
|
+
if (!name || name === old) { loadTree(); return; }
|
|
202
|
+
fsOp({ op: 'rename', path: s.path, name }, res => afterMove(s.path, res.path));
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// ---- menú contextual ----
|
|
207
|
+
let ctxEl = null;
|
|
208
|
+
function closeMenu() { if (ctxEl) { ctxEl.remove(); ctxEl = null; } }
|
|
209
|
+
document.addEventListener('mousedown', ev => { if (ctxEl && !ctxEl.contains(ev.target)) closeMenu(); }, true);
|
|
210
|
+
document.addEventListener('keydown', ev => { if (ev.key === 'Escape') closeMenu(); });
|
|
211
|
+
window.addEventListener('blur', closeMenu); window.addEventListener('resize', closeMenu);
|
|
212
|
+
$('#tree').addEventListener('scroll', closeMenu);
|
|
213
|
+
// e = {path, is_dir, row} o null (raíz); only = 'delete' abre solo la confirmación de borrado (tecla Supr)
|
|
214
|
+
function showMenu(x, y, e, only) {
|
|
215
|
+
closeMenu();
|
|
216
|
+
const isRoot = !e; const path = isRoot ? '.' : e.path; const isDir = isRoot || e.is_dir; const row = e && e.row;
|
|
217
|
+
const dir = isDir ? path : parentOf(path); // carpeta donde se crea o se pega
|
|
218
|
+
const items = [];
|
|
219
|
+
const del = { l: 'Eliminar', k: 'Supr', danger: true, confirm: '¿eliminar ' + baseOf(path) + (isDir ? '/' : '') + '?', f: () => fsOp({ op: 'delete', path }, () => afterDelete(path)) };
|
|
220
|
+
if (only === 'delete') items.push(del);
|
|
221
|
+
else {
|
|
222
|
+
if (!isDir) items.push({ l: 'Abrir', f: () => openFile(path, row) });
|
|
223
|
+
items.push({ l: 'Nuevo archivo…', f: () => newEntry(dir, false) }, { l: 'Nueva carpeta…', f: () => newEntry(dir, true) }, 'sep');
|
|
224
|
+
if (!isRoot) items.push({ l: 'Renombrar', k: 'F2', f: () => renameInline(e) }, { l: 'Duplicar', f: () => fsOp({ op: 'copy', path }) }, 'sep', { l: 'Copiar', k: 'Ctrl+C', f: () => setClip(e, 'copy') }, { l: 'Cortar', k: 'Ctrl+X', f: () => setClip(e, 'cut') });
|
|
225
|
+
items.push({ l: 'Pegar' + (treeClip ? ' «' + baseOf(treeClip.path) + '»' : ''), k: 'Ctrl+V', dis: !canPaste(dir), f: () => pasteInto(dir) }, 'sep');
|
|
226
|
+
items.push({ l: 'Copiar ruta', f: () => copyText(path, row) }, { l: 'Copiar ruta absoluta', dis: !treeRoot, f: () => copyText(absPath(path), row) }, { l: 'Insertar en el chat', f: () => insertInChat(path) });
|
|
227
|
+
if (!isRoot) items.push('sep', del);
|
|
228
|
+
items.push('sep', { l: 'Recargar', f: loadTree });
|
|
229
|
+
}
|
|
230
|
+
const m = document.createElement('div'); m.className = 'ctx';
|
|
231
|
+
for (const it of items) {
|
|
232
|
+
if (it === 'sep') { const s = document.createElement('div'); s.className = 'sep'; m.appendChild(s); continue; }
|
|
233
|
+
const d = document.createElement('div'); d.className = 'it' + (it.dis ? ' dis' : '') + (it.danger ? ' danger' : '');
|
|
234
|
+
d.innerHTML = `<span>${esc(it.l)}</span>` + (it.k ? `<span class="k">${esc(it.k)}</span>` : '');
|
|
235
|
+
d.onclick = ev => { ev.stopPropagation(); if (it.confirm) inlineConfirm(d, it.confirm, () => { closeMenu(); it.f(); }); else { closeMenu(); it.f(); } };
|
|
236
|
+
m.appendChild(d);
|
|
237
|
+
}
|
|
238
|
+
document.body.appendChild(m); ctxEl = m;
|
|
239
|
+
const W = window.innerWidth, H = window.innerHeight, mw = m.offsetWidth, mh = m.offsetHeight;
|
|
240
|
+
m.style.left = Math.max(4, Math.min(x, W - mw - 4)) + 'px'; m.style.top = Math.max(4, Math.min(y, H - mh - 4)) + 'px';
|
|
241
|
+
if (only === 'delete') { const first = m.querySelector('.it'); if (first) first.click(); }
|
|
242
|
+
}
|
|
243
|
+
|
|
42
244
|
async function openFile(path, row) {
|
|
43
245
|
document.querySelectorAll('.row.active').forEach(x => x.classList.remove('active')); if (row) row.classList.add('active');
|
|
44
246
|
const r = await fetch(BASE + '/api/file?path=' + encodeURIComponent(path)); const d = await r.json();
|
package/public/js/workspaces.js
CHANGED
|
@@ -50,7 +50,7 @@ function openWorkspaces(selectSlug) {
|
|
|
50
50
|
}
|
|
51
51
|
function wsNewForm() {
|
|
52
52
|
return `<div class="dhead"><span class="nm serif">Nuevo workspace</span></div><div class="dform">
|
|
53
|
-
<p class="lead">Un workspace es una carpeta de tu disco: el agente solo puede tocar lo que hay adentro. Corre en su propio proceso, con sus sesiones, tareas programadas, MCP y
|
|
53
|
+
<p class="lead">Un workspace es una carpeta de tu disco: el agente solo puede tocar lo que hay adentro. Corre en su propio proceso, con sus sesiones, tareas programadas, MCP y plugins.</p>
|
|
54
54
|
<div class="pickrow"><button class="primary" data-pick>Elegir carpeta…</button><span class="ds">se abre el diálogo de carpetas de tu sistema · <a href="#" data-browse>o explorá desde acá</a></span></div>
|
|
55
55
|
<div class="chosen" data-chosen style="display:none"><span class="lk">carpeta</span><code data-chosen-path></code><span class="del" data-clear title="elegir otra">✕</span></div>
|
|
56
56
|
<div data-browser class="wsbrowser" style="display:none">
|