nio-cat 0.4.1 → 0.4.3
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/bin/nio.js +5 -4
- package/package.json +1 -1
- package/src/commands.js +51 -0
- package/src/server.js +6 -0
- package/web/app/app.js +90 -5
- package/web/app/style.css +11 -0
package/bin/nio.js
CHANGED
|
@@ -74,12 +74,14 @@ function canOpenBrowser() {
|
|
|
74
74
|
return true;
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
+
let panelOpened = false;
|
|
77
78
|
function openDashboard() {
|
|
78
79
|
const local = `http://127.0.0.1:${cfg.port}`;
|
|
79
80
|
// Per defecte s'obre l'app en línia (sempre al dia), ja connectada a aquest ordinador. Amb --local, la còpia que porta nio.
|
|
80
81
|
const online = !flags.local;
|
|
81
|
-
|
|
82
|
-
|
|
82
|
+
// &qr=1: el panell mostra el QR per al mòbil tot just obrir-se, a la mateixa pestanya.
|
|
83
|
+
const link = pairLink(online ? flags.app || cfg.app : local, local, cfg.token) + '&qr=1';
|
|
84
|
+
if (canOpenBrowser() && openUrl(link)) { panelOpened = true; console.log(` ${c.dim('Panell:')} ${online ? (flags.app || cfg.app) + '/app' : local} ${c.dim('(obert al navegador; --no-open per evitar-ho' + (online ? '; --local per fer servir la còpia d\'aquest ordinador' : '') + ')')}`); }
|
|
83
85
|
else console.log(` ${c.dim('Panell:')} ${link}`);
|
|
84
86
|
}
|
|
85
87
|
|
|
@@ -95,8 +97,7 @@ async function showPair(publicUrl) {
|
|
|
95
97
|
console.log(`\n ${c.b('Escaneja aquest codi amb el mòbil')}\n`);
|
|
96
98
|
console.log(terminalQr(qrLink, { margin }));
|
|
97
99
|
} else {
|
|
98
|
-
console.log(`\n ${c.warn('El terminal és massa petit per al QR.')} Fes-lo més gran o obre
|
|
99
|
-
if (canOpenBrowser()) openUrl(file);
|
|
100
|
+
console.log(`\n ${c.warn('El terminal és massa petit per al QR.')} ${panelOpened ? 'El tens al panell del navegador.' : 'Fes-lo més gran, o obre el panell i prem «Continua al mòbil».'}`);
|
|
100
101
|
}
|
|
101
102
|
console.log(`\n ${c.b('Codi:')} ${code}`);
|
|
102
103
|
console.log(c.dim(' Copia\'l i enganxa\'l a Sessions → Tria un ordinador.'));
|
package/package.json
CHANGED
package/src/commands.js
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Comandes pròpies de cada agent que es poden escriure amb «/» a la sessió.
|
|
3
|
+
// Claude Code: les teves comandes (~/.claude/commands i .claude/commands del projecte) i les skills.
|
|
4
|
+
// Es passen a l'agent tal qual («/nom arguments»); el mode d'impressió les entén.
|
|
5
|
+
const fs = require('fs');
|
|
6
|
+
const os = require('os');
|
|
7
|
+
const path = require('path');
|
|
8
|
+
|
|
9
|
+
function frontmatter(file) {
|
|
10
|
+
let txt = '';
|
|
11
|
+
try { txt = fs.readFileSync(file, 'utf8').slice(0, 4000); } catch { return {}; }
|
|
12
|
+
const m = txt.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
|
13
|
+
const out = {};
|
|
14
|
+
if (m) for (const l of m[1].split('\n')) { const kv = l.match(/^([\w-]+):\s*(.*)$/); if (kv) out[kv[1]] = kv[2].trim().replace(/^["']|["']$/g, ''); }
|
|
15
|
+
if (!out.description) {
|
|
16
|
+
const first = txt.replace(/^---[\s\S]*?---\s*/, '').split('\n').map((l) => l.trim()).find((l) => l && !l.startsWith('#'));
|
|
17
|
+
if (first) out.description = first;
|
|
18
|
+
}
|
|
19
|
+
return out;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function listDir(dir) {
|
|
23
|
+
try { return fs.readdirSync(dir, { withFileTypes: true }); } catch { return []; }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function claudeCommands(cwd) {
|
|
27
|
+
const out = new Map();
|
|
28
|
+
const bases = [path.join(os.homedir(), '.claude'), cwd && path.join(cwd, '.claude')].filter(Boolean);
|
|
29
|
+
for (const base of bases) {
|
|
30
|
+
// Comandes: commands/nom.md i commands/espai/nom.md (→ «espai:nom»)
|
|
31
|
+
const cdir = path.join(base, 'commands');
|
|
32
|
+
for (const e of listDir(cdir)) {
|
|
33
|
+
if (e.isFile() && e.name.endsWith('.md')) { const n = e.name.slice(0, -3); out.set(n, { name: n, hint: frontmatter(path.join(cdir, e.name)).description || '', kind: 'comanda' }); }
|
|
34
|
+
else if (e.isDirectory()) for (const f of listDir(path.join(cdir, e.name))) if (f.isFile() && f.name.endsWith('.md')) { const n = `${e.name}:${f.name.slice(0, -3)}`; out.set(n, { name: n, hint: frontmatter(path.join(cdir, e.name, f.name)).description || '', kind: 'comanda' }); }
|
|
35
|
+
}
|
|
36
|
+
// Skills: skills/nom/SKILL.md
|
|
37
|
+
const sdir = path.join(base, 'skills');
|
|
38
|
+
for (const e of listDir(sdir)) {
|
|
39
|
+
if (!e.isDirectory() && !e.isSymbolicLink()) continue;
|
|
40
|
+
const fm = frontmatter(path.join(sdir, e.name, 'SKILL.md'));
|
|
41
|
+
if (fm.description || fs.existsSync(path.join(sdir, e.name, 'SKILL.md'))) { const n = fm.name || e.name; if (/^[\w:.-]+$/.test(n)) out.set(n, { name: n, hint: fm.description || '', kind: 'skill' }); }
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return [...out.values()].map((c) => ({ ...c, hint: c.hint.slice(0, 110) })).sort((a, b) => a.name.localeCompare(b.name)).slice(0, 150);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function agentCommands(agent, cwd) {
|
|
48
|
+
return agent === 'claude' ? claudeCommands(cwd) : [];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
module.exports = { agentCommands };
|
package/src/server.js
CHANGED
|
@@ -11,6 +11,7 @@ const { save } = require('./config');
|
|
|
11
11
|
const { httpErr } = require('./hub');
|
|
12
12
|
const github = require('./github');
|
|
13
13
|
const { addServer, knownHosts } = require('./ssh');
|
|
14
|
+
const { agentCommands } = require('./commands');
|
|
14
15
|
const { pairLink, qrSvg, makeCode, codeLink } = require('./pair');
|
|
15
16
|
|
|
16
17
|
// L'app web: copiada dins del paquet en publicar-lo, o l'arrel del repositori en desenvolupament.
|
|
@@ -269,6 +270,11 @@ function createServer({ cfg, hub, getPublicUrl, getApp }) {
|
|
|
269
270
|
return { ok: true };
|
|
270
271
|
}
|
|
271
272
|
}
|
|
273
|
+
if ((r = m(/^\/api\/sessions\/([\w-]+)\/commands$/)) && method === 'GET') {
|
|
274
|
+
const s = hub.get(r[1]);
|
|
275
|
+
if (!s) throw httpErr(404, 'Aquesta sessió no existeix');
|
|
276
|
+
return agentCommands(s.agent, s.cwd);
|
|
277
|
+
}
|
|
272
278
|
if ((r = m(/^\/api\/sessions\/([\w-]+)\/git(?:\/(commit|pr))?$/))) {
|
|
273
279
|
const s = hub.get(r[1]);
|
|
274
280
|
if (!s) throw httpErr(404, 'Aquesta sessió no existeix');
|
package/web/app/app.js
CHANGED
|
@@ -247,9 +247,14 @@ function forgetMachine(i) {
|
|
|
247
247
|
}
|
|
248
248
|
function checkPairHash() {
|
|
249
249
|
const pair = parsePair(location.hash) || parseCode(location.hash); if (!pair) return false;
|
|
250
|
+
const hashAtStart = location.hash;
|
|
250
251
|
history.replaceState(null, '', location.pathname + '#/sessions');
|
|
251
252
|
state.route = { name: 'sessions' };
|
|
252
|
-
|
|
253
|
+
const wantQr = new URLSearchParams(location.hash.replace(/^#/, '')).get('qr') === '1' || /[#&]qr=1\b/.test(hashAtStart);
|
|
254
|
+
addMachine(pair); render(); toast('Ordinador connectat');
|
|
255
|
+
// Quan nio obre el panell, el QR per al mòbil surt tot seguit, sense cap pestanya nova.
|
|
256
|
+
if (wantQr && !isPhone()) setTimeout(mobileQrSheet, 500);
|
|
257
|
+
return true;
|
|
253
258
|
}
|
|
254
259
|
|
|
255
260
|
/* ---------------------------------------------------------------- navegació */
|
|
@@ -669,23 +674,103 @@ function renderTyping() {
|
|
|
669
674
|
if (!busy) $$('.msg-ai.cursor', feed).forEach((n) => n.classList.remove('cursor'));
|
|
670
675
|
const hb = $('#headBot .bot'); if (hb) hb.dataset.mood = sessionMood(s);
|
|
671
676
|
}
|
|
677
|
+
/* ---------- Comandes amb «/» (les de nio i les de l'agent) */
|
|
678
|
+
const NIO_CMDS = [
|
|
679
|
+
{ name: 'mode', hint: 'Canvia els permisos', args: [['llegir', 'Només llegir', 'l'], ['preguntar', 'Demana permís', 'p'], ['tot', 'Via lliure', 't']] },
|
|
680
|
+
{ name: 'model', hint: 'Canvia de model' },
|
|
681
|
+
{ name: 'stop', hint: 'Atura l’agent' },
|
|
682
|
+
{ name: 'nova', hint: 'Nova sessió amb el mateix agent i carpeta' },
|
|
683
|
+
{ name: 'git', hint: 'Git i GitHub: commit i PR' },
|
|
684
|
+
{ name: 'nom', hint: 'Posa nom a la sessió', free: true },
|
|
685
|
+
{ name: 'opcions', hint: 'Opcions de la sessió' },
|
|
686
|
+
{ name: 'ajuda', hint: 'Llista de comandes' }
|
|
687
|
+
];
|
|
688
|
+
const cmdCache = new Map();
|
|
689
|
+
async function loadCmds(id) {
|
|
690
|
+
if (cmdCache.has(id) || !machine()) return;
|
|
691
|
+
cmdCache.set(id, []);
|
|
692
|
+
try { cmdCache.set(id, await api(`/api/sessions/${id}/commands`)); } catch { /* sense comandes de l'agent */ }
|
|
693
|
+
}
|
|
694
|
+
const agentCmds = (id) => cmdCache.get(id) || [];
|
|
695
|
+
function pickMode(arg) {
|
|
696
|
+
const a = String(arg || '').trim().toLowerCase(); if (!a) return null;
|
|
697
|
+
const m = NIO_CMDS[0].args.find(([k, , al]) => k === a || al === a || k.startsWith(a) || (a === 'lliure' && k === 'tot'));
|
|
698
|
+
return m ? m[0] : null;
|
|
699
|
+
}
|
|
700
|
+
async function runCmd(s, name, arg) {
|
|
701
|
+
const patch = async (body, msg) => { try { const sum = await api(`/api/sessions/${s.id}`, { method: 'PATCH', body }); state.sessions.set(s.id, sum); renderSessionHead(); if (msg) toast(msg); } catch (e) { toast(e.message, 4200); } };
|
|
702
|
+
switch (name) {
|
|
703
|
+
case 'mode': { const m = pickMode(arg); if (!arg) return modeSheet(s); if (!m) return toast('Els permisos són: llegir (l), preguntar (p) o tot (t)'); return patch({ mode: m }, `Permisos: ${MODES[m].label}`); }
|
|
704
|
+
case 'model': return arg ? patch({ model: arg }, `Model: ${arg}`) : modelSheet(s);
|
|
705
|
+
case 'stop': try { await api(`/api/sessions/${s.id}/stop`, { method: 'POST' }); toast('Aturat'); } catch (e) { toast(e.message); } return;
|
|
706
|
+
case 'nova': return newSessionSheet({ agent: s.agent, cwd: s.cwd, mode: s.mode });
|
|
707
|
+
case 'git': return gitSheet(s.id);
|
|
708
|
+
case 'nom': return arg ? patch({ title: arg }, 'Nom desat') : toast('Escriu el nom: /nom Arregla els tests');
|
|
709
|
+
case 'opcions': return sessionOptions(s.id);
|
|
710
|
+
case 'ajuda': return helpCmdsSheet(s);
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
function helpCmdsSheet(s) {
|
|
714
|
+
const ac = agentCmds(s.id);
|
|
715
|
+
openSheet(`<h2>Comandes</h2><p class="muted small">Escriu <span class="mono">/</span> al missatge per veure-les.</p><div class="list">${NIO_CMDS.map((c) => `<div class="card"><b class="mono">/${c.name}</b>${c.args ? ' <span class="muted small mono">l · p · t</span>' : ''}<div class="muted small">${esc(c.hint)}</div></div>`).join('')}</div>${ac.length ? `<p class="muted small">De ${esc(AGENT_NAMES[s.agent] || s.agent)}: ${ac.length} comandes i skills. Se li envien a l’agent tal qual.</p>` : ''}<div class="row sheet-foot"><button class="btn ghost" type="button" data-close>Tanca</button></div>`, (sh) => { $('[data-close]', sh).onclick = closeSheet; });
|
|
716
|
+
}
|
|
717
|
+
// Llista de suggeriments segons el que hi ha escrit: «/», «/mo», «/mode l».
|
|
718
|
+
function slashItems(s, v) {
|
|
719
|
+
const m = v.match(/^\/([\w:.-]*)$/), a = v.match(/^\/mode\s+(\S*)$/i);
|
|
720
|
+
if (a) { const q = a[1].toLowerCase(); return NIO_CMDS[0].args.filter(([k, , al]) => !q || k.startsWith(q) || al === q).map(([k, h, al]) => ({ fill: `/mode ${k}`, run: ['mode', k], label: `/mode ${k}`, hint: `${h} · ${al}`, tag: 'nio' })); }
|
|
721
|
+
if (!m) return [];
|
|
722
|
+
const q = m[1].toLowerCase();
|
|
723
|
+
const nio = NIO_CMDS.map((c) => ({ ...c, tag: 'nio' }));
|
|
724
|
+
const ag = agentCmds(s.id).map((c) => ({ name: c.name, hint: c.hint, tag: AGENT_NAMES[s.agent] || s.agent, agent: true }));
|
|
725
|
+
const all = [...nio, ...ag], starts = all.filter((c) => c.name.toLowerCase().startsWith(q)), has = all.filter((c) => !c.name.toLowerCase().startsWith(q) && c.name.toLowerCase().includes(q));
|
|
726
|
+
return [...starts, ...has].slice(0, 40).map((c) => ({ ...c, label: `/${c.name}` }));
|
|
727
|
+
}
|
|
672
728
|
function renderComposer() {
|
|
673
729
|
const box = $('#composer'); if (!box) return;
|
|
674
730
|
const s = state.sessions.get(state.route.id), busy = !!s && (s.state === 'running' || s.state === 'waiting');
|
|
675
731
|
const prev = $('textarea', box)?.value || '';
|
|
676
732
|
if (box.dataset.busy === String(busy) && $('textarea', box)) return;
|
|
677
733
|
box.dataset.busy = String(busy);
|
|
678
|
-
|
|
734
|
+
if (s) loadCmds(s.id).then(() => { if (state.route.id === s.id) drawSlash(); });
|
|
735
|
+
box.innerHTML = `<div class="slash" id="slash" role="listbox" hidden></div><div class="inner"><textarea rows="1" id="msg" placeholder="${busy ? 'L’agent està treballant…' : 'Escriu a l’agent… (/ per a comandes)'}" enterkeyhint="send" aria-label="Missatge" aria-controls="slash"></textarea><button class="send ${busy ? 'stop' : ''}" id="sendBtn" type="button" aria-label="${busy ? 'Atura l’agent' : 'Envia'}">${svg(busy ? ICON.stop : ICON.send)}</button></div>`;
|
|
679
736
|
const ta = $('#msg'); ta.value = prev;
|
|
680
737
|
const grow = () => autogrow(ta);
|
|
681
|
-
|
|
738
|
+
let items = [], sel = 0;
|
|
739
|
+
const pop = $('#slash');
|
|
740
|
+
function drawSlash() {
|
|
741
|
+
const cur = state.sessions.get(state.route.id); if (!cur || !pop.isConnected) return;
|
|
742
|
+
items = ta.value.includes('\n') ? [] : slashItems(cur, ta.value);
|
|
743
|
+
pop.hidden = !items.length; if (!items.length) return;
|
|
744
|
+
sel = Math.min(sel, items.length - 1);
|
|
745
|
+
pop.innerHTML = items.map((c, i) => `<button type="button" role="option" data-i="${i}" aria-selected="${i === sel}"><span class="n mono">${esc(c.label)}</span><span class="h">${esc(c.hint || '')}</span><span class="t ${c.agent ? 'ag' : ''}">${esc(c.tag)}</span></button>`).join('');
|
|
746
|
+
$('[aria-selected="true"]', pop)?.scrollIntoView({ block: 'nearest' });
|
|
747
|
+
}
|
|
748
|
+
const pick = (i) => {
|
|
749
|
+
const c = items[i]; if (!c) return; const cur = state.sessions.get(state.route.id);
|
|
750
|
+
if (c.run) { ta.value = ''; grow(); drawSlash(); return runCmd(cur, ...c.run); }
|
|
751
|
+
if (c.args || c.free || c.agent) { ta.value = `/${c.name} `; grow(); sel = 0; drawSlash(); ta.focus(); return; }
|
|
752
|
+
ta.value = ''; grow(); drawSlash(); runCmd(cur, c.name, '');
|
|
753
|
+
};
|
|
754
|
+
ta.addEventListener('input', () => { grow(); sel = 0; drawSlash(); });
|
|
755
|
+
pop.addEventListener('pointerdown', (e) => { const b = e.target.closest('[data-i]'); if (b) { e.preventDefault(); pick(+b.dataset.i); } });
|
|
682
756
|
const send = async () => {
|
|
683
757
|
const text = ta.value.trim(); if (!text || !s) return;
|
|
684
|
-
|
|
758
|
+
const c = text.match(/^\/([\w:.-]+)(?:\s+([\s\S]*))?$/), nio = c && NIO_CMDS.find((x) => x.name === c[1].toLowerCase());
|
|
759
|
+
ta.value = ''; grow(); drawSlash();
|
|
760
|
+
if (nio) return runCmd(s, nio.name, (c[2] || '').trim());
|
|
685
761
|
try { await api(`/api/sessions/${s.id}/message`, { method: 'POST', body: { text } }); }
|
|
686
762
|
catch (e) { ta.value = text; grow(); toast(e.message, 4200); }
|
|
687
763
|
};
|
|
688
|
-
ta.addEventListener('keydown', (e) => {
|
|
764
|
+
ta.addEventListener('keydown', (e) => {
|
|
765
|
+
if (items.length && !pop.hidden) {
|
|
766
|
+
if (e.key === 'ArrowDown') { e.preventDefault(); sel = (sel + 1) % items.length; return drawSlash(); }
|
|
767
|
+
if (e.key === 'ArrowUp') { e.preventDefault(); sel = (sel - 1 + items.length) % items.length; return drawSlash(); }
|
|
768
|
+
if (e.key === 'Tab' || (e.key === 'Enter' && !e.shiftKey && !isTouch && !e.isComposing && !/^\/\S+\s/.test(ta.value))) { e.preventDefault(); return pick(sel); }
|
|
769
|
+
if (e.key === 'Escape') { e.preventDefault(); pop.hidden = true; items = []; return; }
|
|
770
|
+
}
|
|
771
|
+
if (e.key === 'Enter' && !e.shiftKey && !isTouch && !e.isComposing) { e.preventDefault(); send(); }
|
|
772
|
+
});
|
|
773
|
+
grow(); drawSlash();
|
|
689
774
|
$('#sendBtn').onclick = async () => { if (busy) { try { await api(`/api/sessions/${s.id}/stop`, { method: 'POST' }); } catch (e) { toast(e.message); } } else send(); };
|
|
690
775
|
}
|
|
691
776
|
|
package/web/app/style.css
CHANGED
|
@@ -469,3 +469,14 @@ video.scan { width: 100%; aspect-ratio: 1; object-fit: cover; border-radius: 14p
|
|
|
469
469
|
.help-body b { color: var(--fg); font-weight: 600; }
|
|
470
470
|
.empty .stepper { text-align: left; max-width: 640px; margin: 16px auto 18px; }
|
|
471
471
|
a.kbd { text-decoration: none; color: inherit; }
|
|
472
|
+
|
|
473
|
+
/* Comandes amb «/» */
|
|
474
|
+
.composer { position: relative; }
|
|
475
|
+
.slash { position: absolute; left: 24px; right: 24px; bottom: 100%; max-width: 760px; margin: 0 auto 6px; max-height: min(320px, 45dvh); overflow-y: auto; overscroll-behavior: contain; display: grid; padding: 6px; border: 1px solid var(--border); border-radius: 14px; background: var(--white); box-shadow: 0 10px 30px rgba(39, 34, 36, .12); z-index: 5; }
|
|
476
|
+
.slash button { display: grid; grid-template-columns: auto 1fr auto; gap: 4px 12px; align-items: baseline; text-align: left; padding: 9px 10px; border: 0; border-radius: 9px; background: none; }
|
|
477
|
+
.slash button[aria-selected="true"] { background: var(--bg-2); }
|
|
478
|
+
.slash .n { font-weight: 600; }
|
|
479
|
+
.slash .h { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--fg-muted); font-size: 13px; }
|
|
480
|
+
.slash .t { font-size: 11px; padding: 1px 7px; border-radius: 6px; background: var(--bg-2); color: var(--fg-muted); }
|
|
481
|
+
.slash .t.ag { background: color-mix(in srgb, var(--lemon) 60%, var(--bg)); color: var(--fg); }
|
|
482
|
+
@media (max-width: 820px) { .slash { left: 12px; right: 12px; } .slash .h { grid-column: 1 / -1; grid-row: 2; } }
|