nio-cat 0.5.0 → 0.5.1

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 CHANGED
@@ -187,10 +187,6 @@ async function start() {
187
187
  if (ip) {
188
188
  publicUrl = publicUrl || `http://${ip}:${cfg.port}`;
189
189
  console.log(` ${c.dim('Xarxa:')} ${publicUrl} ${c.dim('(només la mateixa wifi)')}`);
190
- if (!flags.url) {
191
- // A la xarxa local, l'app es carrega des del mateix daemon (mateix origen).
192
- flags.app = publicUrl;
193
- }
194
190
  }
195
191
  }
196
192
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nio-cat",
3
- "version": "0.5.0",
3
+ "version": "0.5.1",
4
4
  "description": "nio: fes servir Claude Code, Codex, Grok, Antigravity i Ollama del teu ordinador des del mòbil.",
5
5
  "license": "MIT",
6
6
  "author": "f.cat",
@@ -77,7 +77,7 @@ function handle(ctx, line) {
77
77
  }
78
78
  case 'assistant':
79
79
  for (const b of ev.message?.content || []) {
80
- if (b.type === 'text' && b.text) ctx.endStream(b.text);
80
+ if (b.type === 'text' && b.text) { ctx.said = true; ctx.endStream(b.text); }
81
81
  else if (b.type === 'tool_use') ctx.tool({ id: b.id, name: b.name, input: b.input });
82
82
  }
83
83
  break;
@@ -93,6 +93,8 @@ function handle(ctx, line) {
93
93
  break;
94
94
  case 'result':
95
95
  if (ev.session_id) ctx.setNative(ev.session_id);
96
+ // Les comandes locals (/cost, /context…) no passen pel model: la resposta només és aquí.
97
+ if (!ctx.said && !ev.is_error && !ev.num_turns) ctx.endStream(typeof ev.result === 'string' && ev.result.trim() ? ev.result : 'Fet.');
96
98
  ctx.result({
97
99
  cost: ev.total_cost_usd,
98
100
  ms: ev.duration_ms,
package/src/commands.js CHANGED
@@ -23,8 +23,18 @@ function listDir(dir) {
23
23
  try { return fs.readdirSync(dir, { withFileTypes: true }); } catch { return []; }
24
24
  }
25
25
 
26
+ // Comandes internes de Claude Code que també funcionen en mode headless (les altres, com /help o /status, no hi són).
27
+ const CLAUDE_BUILTIN = [
28
+ { name: 'usage', hint: 'Ús de la subscripció i límits' },
29
+ { name: 'context', hint: 'Mostra com s’omple el context' },
30
+ { name: 'compact', hint: 'Resumeix la conversa per alliberar context' },
31
+ { name: 'init', hint: 'Crea un CLAUDE.md al projecte' },
32
+ { name: 'review', hint: 'Revisa els canvis de codi' },
33
+ { name: 'security-review', hint: 'Revisió de seguretat dels canvis' },
34
+ ];
35
+
26
36
  function claudeCommands(cwd) {
27
- const out = new Map();
37
+ const out = new Map(CLAUDE_BUILTIN.map((c) => [c.name, { ...c, kind: 'comanda' }]));
28
38
  const bases = [path.join(os.homedir(), '.claude'), cwd && path.join(cwd, '.claude')].filter(Boolean);
29
39
  for (const base of bases) {
30
40
  // Comandes: commands/nom.md i commands/espai/nom.md (→ «espai:nom»)
@@ -45,6 +55,7 @@ function claudeCommands(cwd) {
45
55
  }
46
56
 
47
57
  function agentCommands(agent, cwd) {
58
+ if (agent === 'codex') return [{ name: 'status', hint: 'Compte, model i carpeta de la sessió', kind: 'comanda' }];
48
59
  return agent === 'claude' ? claudeCommands(cwd) : [];
49
60
  }
50
61
 
package/src/hub.js CHANGED
@@ -163,12 +163,38 @@ class Hub extends EventEmitter {
163
163
  this.emit('out', { type: 'removed', session: sid });
164
164
  }
165
165
 
166
+ // Codex no té /status en mode headless: ho resolem aquí amb el que sí que sap dir.
167
+ codexStatus(s, bp) {
168
+ const { execFile } = require('child_process');
169
+ execFile(bp, ['login', 'status'], { timeout: 8000 }, (err, out, errOut) => {
170
+ const rows = String(out || errOut || '').split('\n').map((l) => l.trim()).filter(Boolean), login = rows.find((l) => /logged|log in|login/i.test(l) && !/^warning/i.test(l)) || rows[0] || (err ? 'No he pogut llegir el compte' : '');
171
+ const lines = [`**Compte:** ${login}`, `**Model:** ${s.model || s.modelUsed || 'el de per defecte'}`, `**Carpeta:** \`${s.cwd}\``, `**Permisos:** ${s.mode}`, `**Conversa:** ${s.nativeId ? 'continua la de Codex' : 'encara no ha començat'}`];
172
+ this.push(s, 'text', { text: lines.join('\n\n') });
173
+ this.touch(s);
174
+ });
175
+ return this.summary(s);
176
+ }
177
+
166
178
  update(sid, patch) {
167
179
  const s = this.get(sid);
168
180
  if (!s) throw httpErr(404, 'Aquesta sessió no existeix');
169
181
  if (patch.mode && ['llegir', 'preguntar', 'tot'].includes(patch.mode)) s.mode = patch.mode;
170
182
  if (typeof patch.title === 'string') s.title = patch.title.slice(0, 120);
171
183
  if (typeof patch.model === 'string') s.model = patch.model.slice(0, 80);
184
+ if (typeof patch.cwd === 'string' && path.resolve(patch.cwd) !== s.cwd) {
185
+ const cwd = path.resolve(patch.cwd);
186
+ if (s.state === 'running' || s.state === 'waiting') throw httpErr(409, 'Espera que l’agent acabi o atura’l abans de canviar de carpeta');
187
+ let ok = false;
188
+ try { ok = fs.statSync(cwd).isDirectory(); } catch { /* no existeix */ }
189
+ if (!ok) throw httpErr(400, 'Aquesta carpeta no existeix');
190
+ // Claude Code desa cada conversa dins la carpeta del projecte: la copiem perquè l'agent la recordi.
191
+ if (s.agent === 'claude' && s.nativeId) {
192
+ const enc = (d) => path.join(require('os').homedir(), '.claude', 'projects', d.replace(/[^a-zA-Z0-9]/g, '-'), `${s.nativeId}.jsonl`);
193
+ try { fs.mkdirSync(path.dirname(enc(cwd)), { recursive: true }); fs.copyFileSync(enc(s.cwd), enc(cwd)); } catch { s.nativeId = null; }
194
+ }
195
+ s.cwd = cwd;
196
+ this.push(s, 'info', { text: `Carpeta canviada a ${cwd}` });
197
+ }
172
198
  this.touch(s);
173
199
  return this.summary(s);
174
200
  }
@@ -201,6 +227,7 @@ class Hub extends EventEmitter {
201
227
 
202
228
  if (!s.title) s.title = text.replace(/\s+/g, ' ').slice(0, 60);
203
229
  this.push(s, 'user', { text });
230
+ if (s.agent === 'codex' && /^\/status\s*$/i.test(text)) return this.codexStatus(s, bp);
204
231
  s.state = 'running';
205
232
  this.push(s, 'status', { state: 'running' });
206
233
 
package/web/app/app.js CHANGED
@@ -582,7 +582,7 @@ function viewSessions() {
582
582
  const many = store.machines.length > 1, mach = machine();
583
583
  const switcher = many ? `<button class="chip switch-chip" type="button" data-act="machines"><i class="dot ${state.connected ? 'on' : ''}"></i>${esc(mach.name)}${svg(ICON.chev)}</button>` : '';
584
584
  const hint = mobileHint();
585
- const head = `<div class="top"><div><h1>Sessions</h1><p>Cada sessió és una conversa amb un agent, dins d’una carpeta.</p>${switcher}</div><button class="btn ink" data-act="new" type="button">${svg(ICON.plus)}Nova sessió</button></div>${hint}`;
585
+ const head = `<div class="top"><div><h1>Sessions</h1><p>Cada sessió és una conversa amb un agent, dins d’una carpeta.</p>${switcher}</div><button class="btn ink new-fab" data-act="new" type="button">${svg(ICON.plus)}Nova sessió</button></div>${hint}`;
586
586
  if (!state.connected && !list.length) {
587
587
  return `${head}<div class="panel empty">${bot(state.lastError ? 'sleep' : 'work')}<h2>${esc(state.lastError || 'Connectant amb l’ordinador…')}</h2>${state.lastError ? `<p>${mach.kind === 'vps' ? 'Potser el servidor s’ha reiniciat i l’adreça ha canviat.' : 'Comprova que nio continua engegat a l’ordinador i que el túnel està obert.'}</p><div class="row" style="justify-content:center"><button class="btn" data-act="retry" type="button">${svg(ICON.refresh)}Torna-ho a provar</button>${mach.kind === 'vps' && mach.via ? `<button class="btn ink" data-reconnect="${store.active}" type="button">Torna a connectar</button>` : ''}<a class="btn ghost" href="#/ordinadors">Ordinadors</a></div>` : ''}</div>`;
588
588
  }
@@ -680,7 +680,7 @@ function renderSessionHead() {
680
680
  const ag = state.info?.agents?.find((a) => a.id === s.agent);
681
681
  bar.innerHTML = `<span class="chip"><span class="agent-tag nosq" data-agent="${esc(s.agent)}">${agentBot(s.agent, sessionMood(s))}${esc(AGENT_NAMES[s.agent] || s.agent)}</span></span>
682
682
  ${ag?.noModes ? '' : `<button class="chip" data-act="mode" type="button">${esc(MODES[s.mode]?.label || s.mode)}</button>`}
683
- <span class="chip mono">${esc(basename(s.cwd))}</span>
683
+ <button class="chip mono" data-act="folder" type="button" title="${esc(s.cwd)} · canvia de carpeta">${esc(basename(s.cwd))}</button>
684
684
  <button class="chip mono" data-act="model" type="button" title="Canvia de model">${esc(s.model || 'Model per defecte')}</button>
685
685
  ${s.cost ? `<span class="chip mono">${s.cost.toFixed(2)} $</span>` : ''}
686
686
  ${stateChip(s)}`;
@@ -757,6 +757,7 @@ const NIO_CMDS = [
757
757
  { name: 'model', hint: 'Canvia de model' },
758
758
  { name: 'stop', hint: 'Atura l’agent' },
759
759
  { name: 'nova', hint: 'Nova sessió amb el mateix agent i carpeta' },
760
+ { name: 'carpeta', hint: 'Canvia la carpeta de la sessió' },
760
761
  { name: 'git', hint: 'Git i GitHub: commit i PR' },
761
762
  { name: 'nom', hint: 'Posa nom a la sessió', free: true },
762
763
  { name: 'opcions', hint: 'Opcions de la sessió' },
@@ -781,12 +782,33 @@ async function runCmd(s, name, arg) {
781
782
  case 'model': return arg ? patch({ model: arg }, `Model: ${arg}`) : modelSheet(s);
782
783
  case 'stop': try { await api(`/api/sessions/${s.id}/stop`, { method: 'POST' }); toast('Aturat'); } catch (e) { toast(e.message); } return;
783
784
  case 'nova': return newSessionSheet({ agent: s.agent, cwd: s.cwd, mode: s.mode });
785
+ case 'carpeta': return folderSheet(s);
784
786
  case 'git': return gitSheet(s.id);
785
787
  case 'nom': return arg ? patch({ title: arg }, 'Nom desat') : toast('Escriu el nom: /nom Arregla els tests');
786
788
  case 'opcions': return sessionOptions(s.id);
787
789
  case 'ajuda': return helpCmdsSheet(s);
788
790
  }
789
791
  }
792
+ function folderSheet(s) {
793
+ let d = null;
794
+ const draw = () => {
795
+ const crumbs = d ? d.path.split(/[\\/]/).filter(Boolean) : [];
796
+ $('#fsBody').innerHTML = !d ? '<p class="muted small">Carregant…</p>' : `<div class="fs-bar"><button class="icon-btn" type="button" data-fs="${esc(d.parent || '')}" ${d.parent ? '' : 'disabled'} aria-label="Carpeta de dalt">${svg(ICON.back)}</button><button class="chip" type="button" data-fs="${esc(d.home)}">Inici</button><span class="fs-path mono small" title="${esc(d.path)}">${esc(crumbs.slice(-2).join(' / ') || '/')}</span></div>
797
+ <div class="proj-list fs-list">${d.dirs.map((x) => `<button type="button" class="proj" data-fs="${esc(x.path)}"><span class="ic">›</span><span class="grow"><div ${x.hidden ? 'class="muted"' : ''}>${esc(x.name)}</div></span></button>`).join('') || '<p class="muted small">No hi ha subcarpetes. Pots triar aquesta.</p>'}</div>`;
798
+ const ok = $('#fsOk'); if (ok) { ok.disabled = !d || d.path === s.cwd; ok.textContent = d && d.path === s.cwd ? 'Ja hi és' : 'Treballa aquí'; }
799
+ };
800
+ const go = async (p) => { try { d = await api(`/api/fs?path=${encodeURIComponent(p || '')}`); } catch (e) { toast(e.message, 4200); } draw(); };
801
+ openSheet(`<h2>Canvia de carpeta</h2><p class="muted small">Ara: <span class="mono">${esc(s.cwd)}</span>. La conversa continua i l’agent la recorda.</p><div id="fsBody"></div><div class="row between sheet-foot"><button class="btn ghost" type="button" data-close>Cancel·la</button><button class="btn ink" id="fsOk" type="button" disabled>Treballa aquí</button></div>`, (sh) => {
802
+ sh.onclick = async (e) => {
803
+ if (e.target.closest('[data-close]')) return closeSheet();
804
+ const f = e.target.closest('[data-fs]'); if (f && !f.disabled) return go(f.dataset.fs);
805
+ if (e.target.closest('#fsOk') && d) {
806
+ try { const sum = await api(`/api/sessions/${s.id}`, { method: 'PATCH', body: { cwd: d.path } }); state.sessions.set(s.id, sum); closeSheet(); renderSessionHead(); cmdCache.delete(s.id); toast(`Carpeta: ${basename(sum.cwd)}`); } catch (er) { toast(er.message, 4200); }
807
+ }
808
+ };
809
+ draw(); go(s.cwd);
810
+ });
811
+ }
790
812
  function helpCmdsSheet(s) {
791
813
  const ac = agentCmds(s.id);
792
814
  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; });
@@ -798,7 +820,7 @@ function slashItems(s, v) {
798
820
  if (!m) return [];
799
821
  const q = m[1].toLowerCase();
800
822
  const nio = NIO_CMDS.map((c) => ({ ...c, tag: 'nio' }));
801
- const ag = agentCmds(s.id).map((c) => ({ name: c.name, hint: c.hint, tag: AGENT_NAMES[s.agent] || s.agent, agent: true }));
823
+ const ag = agentCmds(s.id).map((c) => ({ name: c.name, hint: c.hint, tag: (AGENT_NAMES[s.agent] || s.agent).split(' ')[0], agent: true }));
802
824
  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));
803
825
  return [...starts, ...has].slice(0, 40).map((c) => ({ ...c, label: `/${c.name}` }));
804
826
  }
@@ -809,7 +831,7 @@ function renderComposer() {
809
831
  if (box.dataset.busy === String(busy) && $('textarea', box)) return;
810
832
  box.dataset.busy = String(busy);
811
833
  if (s) loadCmds(s.id).then(() => { if (state.route.id === s.id) drawSlash(); });
812
- 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="mic" id="micBtn" type="button" data-mic aria-label="Dicta amb el micròfon">${svg(ICON.mic)}</button><button class="send ${busy ? 'stop' : ''}" id="sendBtn" type="button" aria-label="${busy ? 'Atura l’agent' : 'Envia'}">${svg(busy ? ICON.stop : ICON.send)}</button></div>`;
834
+ 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…'}" enterkeyhint="send" aria-label="Missatge" aria-controls="slash"></textarea><button class="mic" id="micBtn" type="button" data-mic aria-label="Dicta amb el micròfon">${svg(ICON.mic)}</button><button class="send ${busy ? 'stop' : ''}" id="sendBtn" type="button" aria-label="${busy ? 'Atura l’agent' : 'Envia'}">${svg(busy ? ICON.stop : ICON.send)}</button></div>`;
813
835
  const ta = $('#msg'); ta.value = prev;
814
836
  const grow = () => autogrow(ta);
815
837
  let items = [], sel = 0;
@@ -829,7 +851,8 @@ function renderComposer() {
829
851
  ta.value = ''; grow(); drawSlash(); runCmd(cur, c.name, '');
830
852
  };
831
853
  ta.addEventListener('input', () => { grow(); sel = 0; drawSlash(); });
832
- pop.addEventListener('pointerdown', (e) => { const b = e.target.closest('[data-i]'); if (b) { e.preventDefault(); pick(+b.dataset.i); } });
854
+ pop.addEventListener('mousedown', (e) => e.preventDefault());
855
+ pop.addEventListener('click', (e) => { const b = e.target.closest('[data-i]'); if (b) pick(+b.dataset.i); });
833
856
  const send = async () => {
834
857
  const text = ta.value.trim(); if (!text || !s) return;
835
858
  const c = text.match(/^\/([\w:.-]+)(?:\s+([\s\S]*))?$/), nio = c && NIO_CMDS.find((x) => x.name === c[1].toLowerCase());
@@ -1433,6 +1456,7 @@ document.addEventListener('click', async (e) => {
1433
1456
  case 'open-sessions': return go('#/sessions');
1434
1457
  case 'help': return go('#/ajuda');
1435
1458
  case 'more': return moreSheet();
1459
+ case 'folder': { const s = state.sessions.get(state.route.id); return s && folderSheet(s); }
1436
1460
  case 'machines': return machinesSheet();
1437
1461
  case 'mobile-qr': return mobileQrSheet();
1438
1462
  case 'mobile-hint-off': { const m = machine(); LS.set(`nio.mobHint.${m?.id || m?.url}`, true); return render(); }
package/web/app/style.css CHANGED
@@ -482,13 +482,14 @@ a.kbd { text-decoration: none; color: inherit; }
482
482
  /* Comandes amb «/» */
483
483
  .composer { position: relative; }
484
484
  .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; }
485
- .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; }
485
+ .slash button { display: grid; grid-template-columns: minmax(0, auto) minmax(0, 1fr) auto; gap: 2px 12px; align-items: center; text-align: left; padding: 9px 10px; border: 0; border-radius: 9px; background: none; width: 100%; }
486
486
  .slash button[aria-selected="true"] { background: var(--bg-2); }
487
- .slash .n { font-weight: 600; }
487
+ .slash .n { font-weight: 600; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
488
488
  .slash .h { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--fg-muted); font-size: 13px; }
489
- .slash .t { font-size: 11px; padding: 1px 7px; border-radius: 6px; background: var(--bg-2); color: var(--fg-muted); }
489
+ .slash .t { display: inline-flex; align-items: center; justify-content: center; height: 20px; padding: 0 8px; border-radius: 10px; background: var(--bg-2); color: var(--fg-muted); font-size: 11px; font-weight: 600; line-height: 1; white-space: nowrap; }
490
+ .slash button[aria-selected="true"] .t { background: var(--white); }
490
491
  .slash .t.ag { background: color-mix(in srgb, var(--lemon) 60%, var(--bg)); color: var(--fg); }
491
- @media (max-width: 820px) { .slash { left: 12px; right: 12px; } .slash .h { grid-column: 1 / -1; grid-row: 2; } }
492
+ @media (max-width: 820px) { .slash { left: 12px; right: 12px; } .slash button { grid-template-columns: minmax(0, 1fr) auto; } .slash .h { grid-column: 1 / -1; grid-row: 2; } .slash .t { grid-column: 2; grid-row: 1; } }
492
493
  /* ---------- Veu ---------- */
493
494
  .ev-error b { font-size: 12px; letter-spacing: .02em; text-transform: uppercase; }
494
495
  .speak { display: inline-flex; align-items: center; gap: 6px; margin-top: 8px; height: 28px; padding: 0 10px; border: 1px solid var(--border); border-radius: 999px; background: none; color: var(--fg-muted); font-size: 12px; font-weight: 600; }
@@ -563,3 +564,8 @@ dialog.sheet.wiz.compact .sheet-in, .compact .wiz-body { flex: 0 1 auto; }
563
564
  .chip .agent-tag { gap: 7px; }
564
565
  .sheet .list + .or { margin-top: 18px; }
565
566
  .sheet #modelForm { margin-top: 4px; }
567
+
568
+ @media (max-width: 820px) {
569
+ .new-fab { position: fixed; right: 16px; bottom: calc(72px + var(--safe-b)); z-index: 25; height: 48px; padding: 0 18px 0 14px; border-radius: 16px; box-shadow: 0 14px 30px -14px color-mix(in srgb, var(--fg) 70%, transparent); }
570
+ body:not(.no-tabs):not(.kb) .main:has(.new-fab) { padding-bottom: calc(130px + var(--safe-b)); }
571
+ }
package/web/sw.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // nio · service worker: obre l'app encara que no hi hagi connexió i mostra els avisos.
2
- const SHELL = 'nio-shell-v14';
2
+ const SHELL = 'nio-shell-v15';
3
3
  const FILES = ['/app', '/app/style.css', '/app/app.js', '/bot.css', '/bot.js', '/board.css', '/board.js', '/favicon.svg', '/manifest.webmanifest'];
4
4
 
5
5
  self.addEventListener('install', (e) => {