nio-cat 0.4.1 → 0.4.4

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
@@ -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
- const link = pairLink(online ? flags.app || cfg.app : local, local, cfg.token);
82
- if (canOpenBrowser() && openUrl(link)) 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' : '') + ')')}`);
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 ${file}`);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nio-cat",
3
- "version": "0.4.1",
3
+ "version": "0.4.4",
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",
@@ -15,10 +15,15 @@ module.exports = {
15
15
 
16
16
  start(ctx) {
17
17
  const { session, prompt, cfg } = ctx;
18
- const args = ['-p', prompt, '--output-format', 'stream-json', '--verbose'];
18
+ const args = ['-p', prompt, '--output-format', 'stream-json', '--verbose', '--include-partial-messages'];
19
19
  if (session.nativeId) args.push('--resume', session.nativeId);
20
20
  if (session.model) args.push('--model', session.model);
21
21
 
22
+ if (session.mode === 'tot' && process.getuid?.() === 0) {
23
+ ctx.fail('Claude Code no deixa el mode «Via lliure» quan s’executa com a root. Canvia els permisos a «Demana permís» o engega nio amb un usuari normal.');
24
+ return { kill() {} };
25
+ }
26
+
22
27
  const env = {};
23
28
  if (session.mode === 'llegir') {
24
29
  args.push('--permission-mode', 'plan');
@@ -65,9 +70,14 @@ function handle(ctx, line) {
65
70
  if (ev.model) ctx.meta({ model: ev.model });
66
71
  }
67
72
  break;
73
+ case 'stream_event': {
74
+ const d = ev.event?.type === 'content_block_delta' ? ev.event.delta : null;
75
+ if (d?.type === 'text_delta' && d.text) ctx.stream(d.text);
76
+ break;
77
+ }
68
78
  case 'assistant':
69
79
  for (const b of ev.message?.content || []) {
70
- if (b.type === 'text' && b.text) ctx.text(b.text);
80
+ if (b.type === 'text' && b.text) ctx.endStream(b.text);
71
81
  else if (b.type === 'tool_use') ctx.tool({ id: b.id, name: b.name, input: b.input });
72
82
  }
73
83
  break;
@@ -91,6 +101,6 @@ function handle(ctx, line) {
91
101
  });
92
102
  break;
93
103
  default:
94
- break; // stream_event, rate_limit_event, etc.
104
+ break; // rate_limit_event, etc.
95
105
  }
96
106
  }
@@ -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/hub.js CHANGED
@@ -72,6 +72,7 @@ class Hub extends EventEmitter {
72
72
  updated: s.updated,
73
73
  cost: s.cost || 0,
74
74
  last: lastText ? clip(lastText.text, 160) : '',
75
+ error: s.state === 'error' ? clip([...s.events].reverse().find((e) => e.kind === 'error')?.text || '', 200) : '',
75
76
  pending: [...this.approvals.values()].filter((a) => a.session === s.id && a.state === 'pending').length,
76
77
  };
77
78
  }
@@ -116,8 +117,20 @@ class Hub extends EventEmitter {
116
117
  this.persist(s);
117
118
  return;
118
119
  }
119
- const ev = this.push(s, 'text', { text });
120
- ev.open = true;
120
+ this.push(s, 'text', { text, open: true });
121
+ }
122
+
123
+ // Tanca el bloc de text en curs amb el text definitiu (o l'afegeix si no hi havia streaming).
124
+ endStream(s, text) {
125
+ const last = s.events[s.events.length - 1];
126
+ if (last && last.kind === 'text' && last.open) {
127
+ last.text = text;
128
+ delete last.open;
129
+ this.emit('out', { type: 'event', session: s.id, event: last });
130
+ this.touch(s);
131
+ return;
132
+ }
133
+ this.push(s, 'text', { text });
121
134
  }
122
135
 
123
136
  // ---------- sessions ----------
@@ -194,6 +207,8 @@ class Hub extends EventEmitter {
194
207
  const key = id(16);
195
208
  const toolNames = new Map();
196
209
  let finished = false;
210
+ let reported = false;
211
+ const tail = [];
197
212
  const ctx = {
198
213
  session: s,
199
214
  prompt: text,
@@ -212,6 +227,7 @@ class Hub extends EventEmitter {
212
227
  this.touch(s);
213
228
  },
214
229
  text: (t) => this.push(s, 'text', { text: String(t) }),
230
+ endStream: (t) => this.endStream(s, String(t)),
215
231
  stream: (t) => this.chunk(s, String(t)),
216
232
  tool: ({ id: tid, name, input }) => {
217
233
  toolNames.set(tid, name);
@@ -223,10 +239,19 @@ class Hub extends EventEmitter {
223
239
  if (typeof r.cost === 'number') s.cost = (s.cost || 0) + r.cost;
224
240
  this.push(s, 'result', r);
225
241
  },
226
- error: (msg) => this.push(s, 'error', { text: clip(typeof msg === 'string' ? msg : JSON.stringify(msg), 1200) }),
242
+ error: (msg) => {
243
+ reported = true;
244
+ this.push(s, 'error', { text: clip(typeof msg === 'string' ? msg : JSON.stringify(msg), 1200) });
245
+ },
246
+ fail: (msg) => {
247
+ ctx.error(msg);
248
+ queueMicrotask(() => ctx.done(1));
249
+ },
227
250
  log: (line) => {
228
- // Les línies de stderr només es guarden; el mòbil les mostra plegades.
251
+ // Les línies de stderr es guarden (el mòbil les mostra plegades) i les últimes expliquen un error.
229
252
  if (/^\s*$/.test(line)) return;
253
+ tail.push(line.trim());
254
+ if (tail.length > 4) tail.shift();
230
255
  this.push(s, 'log', { text: clip(line, 600) });
231
256
  },
232
257
  ask: ({ tool, input }) => this.requestApproval({ session: s.id, tool, input }),
@@ -238,8 +263,9 @@ class Hub extends EventEmitter {
238
263
  if (a.session === sid && a.state === 'pending') this.decide(a.id, 'deny', 'La sessió s’ha acabat');
239
264
  }
240
265
  if (err) this.push(s, 'error', { text: `No s’ha pogut engegar: ${err.message}` });
241
- else if (code && code !== 0 && code !== 130 && code !== 143) {
242
- this.push(s, 'error', { text: `L’agent s’ha aturat amb un error (codi ${code})` });
266
+ else if (code && code !== 0 && code !== 130 && code !== 143 && !reported) {
267
+ const why = explain(tail);
268
+ this.push(s, 'error', { text: why || `${agentName(s)} s’ha aturat amb un error (codi ${code})${tail.length ? `:\n${tail.join('\n')}` : ''}` });
243
269
  }
244
270
  s.state = code === 130 || code === 143 ? 'idle' : code && code !== 0 ? 'error' : 'idle';
245
271
  this.push(s, 'status', { state: s.state, code });
@@ -342,6 +368,15 @@ class Hub extends EventEmitter {
342
368
  }
343
369
  }
344
370
 
371
+ // Causes conegudes d'una sortida amb error, explicades en català.
372
+ function explain(lines) {
373
+ const t = lines.join('\n');
374
+ if (/cannot be used with root/i.test(t)) return 'Claude Code no deixa el mode «Via lliure» quan s’executa com a root. Canvia els permisos a «Demana permís» o engega nio amb un usuari normal.';
375
+ if (/not logged in|please run \/login|invalid api key|authentication/i.test(t)) return 'L’agent no té la sessió iniciada en aquest ordinador. Entra-hi per terminal i inicia sessió (per exemple, executant «claude»).';
376
+ if (/rate limit|usage limit|quota|overloaded|429/i.test(t)) return `L’agent ha arribat al límit d’ús o està saturat. Torna-ho a provar d’aquí una estona.\n${lines.slice(-1)[0] || ''}`.trim();
377
+ return '';
378
+ }
379
+
345
380
  function agentName(s) {
346
381
  return (s && byId[s.agent]?.name) || 'L’agent';
347
382
  }
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
@@ -31,8 +31,10 @@ const ICON = {
31
31
  server: '<rect x="3" y="4" width="18" height="6" rx="2"/><rect x="3" y="14" width="18" height="6" rx="2"/><path d="M7 7h.01M7 17h.01"/>',
32
32
  scan: '<path d="M4 8V5a1 1 0 0 1 1-1h3M16 4h3a1 1 0 0 1 1 1v3M20 16v3a1 1 0 0 1-1 1h-3M8 20H5a1 1 0 0 1-1-1v-3M4 12h16"/>',
33
33
  check: '<path class="tick" d="M5 12.5l4.5 4.5L19 7.5"/>',
34
+ mic: '<rect x="9" y="3" width="6" height="11" rx="3"/><path d="M5 11a7 7 0 0 0 14 0M12 18v3"/>', speaker: '<path d="M4 9.5v5h3.5L12 18.5v-13L7.5 9.5zM15.5 9a4 4 0 0 1 0 6M18 6.5a8 8 0 0 1 0 11"/>',
34
35
  copy: '<rect x="8" y="8" width="12" height="12" rx="2"/><path d="M16 8V5a1 1 0 0 0-1-1H5a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1h3"/>'
35
36
  };
37
+ const agentBot = (agent, mood = 'idle') => `<span class="abot" data-agent="${esc(agent)}">${bot(mood)}</span>`;
36
38
  const AGENT_NAMES = { claude: 'Claude Code', codex: 'Codex', grok: 'Grok', antigravity: 'Antigravity', ollama: 'Ollama', demo: 'Demo', browser: 'Xat local' };
37
39
  const MODES = {
38
40
  llegir: { label: 'Només llegir', short: 'Llegir' },
@@ -247,9 +249,14 @@ function forgetMachine(i) {
247
249
  }
248
250
  function checkPairHash() {
249
251
  const pair = parsePair(location.hash) || parseCode(location.hash); if (!pair) return false;
252
+ const hashAtStart = location.hash;
250
253
  history.replaceState(null, '', location.pathname + '#/sessions');
251
254
  state.route = { name: 'sessions' };
252
- addMachine(pair); render(); toast('Ordinador connectat'); return true;
255
+ const wantQr = new URLSearchParams(location.hash.replace(/^#/, '')).get('qr') === '1' || /[#&]qr=1\b/.test(hashAtStart);
256
+ addMachine(pair); render(); toast('Ordinador connectat');
257
+ // Quan nio obre el panell, el QR per al mòbil surt tot seguit, sense cap pestanya nova.
258
+ if (wantQr && !isPhone()) setTimeout(mobileQrSheet, 500);
259
+ return true;
253
260
  }
254
261
 
255
262
  /* ---------------------------------------------------------------- navegació */
@@ -371,7 +378,7 @@ function viewComputers() {
371
378
  <section class="panel"><header><h2>Avisos al mòbil</h2></header><div class="body stack"><p class="small">Rep un avís quan un agent et demana permís o acaba la feina.</p>${isIOS && !standalone ? '<p class="muted small">A l’iPhone, primer afegeix nio a la pantalla d’inici (Comparteix → Afegeix a la pantalla d’inici) i obre’l des d’allà.</p>' : ''}<div class="row"><button class="btn ink grow" data-act="push" type="button">Activa els avisos</button><button class="btn" data-act="push-test" type="button">Envia’n un de prova</button></div></div></section>
372
379
  </div>
373
380
  </div>` : '';
374
- return `${head}<div class="computers">${ms.map(computerCard).join('')}</div>${detail}${helpPanel()}`;
381
+ return `${head}<div class="computers">${ms.map(computerCard).join('')}</div>${detail}${voicePanel()}${helpPanel()}`;
375
382
  }
376
383
 
377
384
  /* ---------- Afegir un ordinador: aquest, un servidor per SSH o un codi */
@@ -485,7 +492,7 @@ function machinesSheet() {
485
492
  <div class="row between sheet-foot">${ms.length ? '<a class="btn ghost" href="#/ordinadors" data-close>Gestiona</a>' : '<button class="btn ghost" type="button" data-close>Tanca</button>'}<button class="btn ink" type="button" data-act="add-computer">${svg(ICON.plus)}Afegeix ordinador</button></div>`, (sh) => { $$('[data-close]', sh).forEach((b) => (b.onclick = closeSheet)); });
486
493
  }
487
494
  function moreSheet() {
488
- openSheet(`<h2>Més</h2><div class="list">${['models', 'mobil', 'pissarra'].map((k) => `<a class="card row" href="${HASH[k]}" style="gap:12px">${svg(VIEWS[k].icon)}<b>${VIEWS[k].label}</b></a>`).join('')}<a class="card row" href="#/ordinadors" data-act="help" style="gap:12px"><svg class="ico" viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M12 8v5M12 16v.1"/></svg><b>Com es fa</b></a><button class="card row" type="button" data-act="tour" style="gap:12px"><svg class="ico" viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M9.6 9.3a2.5 2.5 0 0 1 4.8.9c0 1.7-2.4 2.2-2.4 3.6M12 17.2v.1"/></svg><b>Tutorial</b></button></div>`, (sh) => { sh.onclick = (e) => { if (e.target.closest('a, [data-act]')) closeSheet(); }; });
495
+ openSheet(`<h2>Més</h2><div class="list">${['models', 'mobil', 'pissarra'].map((k) => `<a class="card row" href="${HASH[k]}" style="gap:12px">${svg(VIEWS[k].icon)}<b>${VIEWS[k].label}</b></a>`).join('')}<button class="card row" type="button" data-act="voice" style="gap:12px">${svg(ICON.mic)}<b>Veu</b></button><a class="card row" href="#/ordinadors" data-act="help" style="gap:12px"><svg class="ico" viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M12 8v5M12 16v.1"/></svg><b>Com es fa</b></a><button class="card row" type="button" data-act="tour" style="gap:12px"><svg class="ico" viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M9.6 9.3a2.5 2.5 0 0 1 4.8.9c0 1.7-2.4 2.2-2.4 3.6M12 17.2v.1"/></svg><b>Tutorial</b></button></div>`, (sh) => { sh.onclick = (e) => { if (e.target.closest('a, [data-act]')) closeSheet(); }; });
489
496
  }
490
497
 
491
498
  /* ---------- Sessions */
@@ -506,9 +513,9 @@ function viewSessions() {
506
513
  }
507
514
  return `${head}${list.length ? `<div class="list">${list.map((s) => `
508
515
  <a class="card session-card" href="#/s/${esc(s.id)}">
509
- <div class="row between"><span class="agent-tag" data-agent="${esc(s.agent)}">${esc(AGENT_NAMES[s.agent] || s.agent)}</span>${stateChip(s)}</div>
516
+ <div class="row between"><span class="agent-tag nosq" data-agent="${esc(s.agent)}">${agentBot(s.agent, sessionMood(s))}${esc(AGENT_NAMES[s.agent] || s.agent)}</span>${stateChip(s)}</div>
510
517
  <div class="title">${esc(s.title || 'Sessió nova')}</div>
511
- ${s.last ? `<div class="last">${esc(s.last)}</div>` : ''}
518
+ ${s.state === 'error' && s.error ? `<div class="last err">${esc(s.error)}</div>` : s.last ? `<div class="last">${esc(s.last)}</div>` : ''}
512
519
  <div class="meta"><span class="mono">${esc(basename(s.cwd))}</span><span>·</span><span>${esc(MODES[s.mode]?.label || '')}</span><span>·</span><span>${ago(s.updated)}</span></div>
513
520
  </a>`).join('')}</div>` : `<div class="panel empty">${bot('idle')}<h2>Encara no hi ha cap sessió</h2><p>Tria un agent i una carpeta, i explica-li què ha de fer.</p><button class="btn ink" data-act="new" type="button">${svg(ICON.plus)}Nova sessió</button></div>`}`;
514
521
  }
@@ -573,7 +580,7 @@ let feedEls = new Map(), toolEls = new Map();
573
580
  function renderSession() {
574
581
  const id = state.route.id, view = $('#view');
575
582
  view.innerHTML = `<section class="chat">
576
- <div class="chat-head"><a class="icon-btn" href="#/sessions" aria-label="Torna a les sessions">${svg(ICON.back)}</a><span id="headBot"></span><div class="grow"><h1 id="sessTitle"></h1><div class="sub" id="sessSub"></div></div><button class="icon-btn" data-act="session-more" aria-label="Opcions de la sessió">${svg(ICON.more)}</button></div>
583
+ <div class="chat-head"><a class="icon-btn" href="#/sessions" aria-label="Torna a les sessions">${svg(ICON.back)}</a><span id="headBot" class="abot"></span><div class="grow"><h1 id="sessTitle"></h1><div class="sub" id="sessSub"></div></div><button class="icon-btn" data-act="session-more" aria-label="Opcions de la sessió">${svg(ICON.more)}</button></div>
577
584
  <div class="mode-bar" id="modeBar"></div>
578
585
  <div class="feed" id="feed" aria-live="polite"></div>
579
586
  <div class="composer" id="composer"></div>
@@ -593,10 +600,10 @@ function renderSessionHead() {
593
600
  if (!bar) return;
594
601
  $('#sessTitle').textContent = s?.title || 'Sessió nova';
595
602
  $('#sessSub').textContent = s ? `${AGENT_NAMES[s.agent] || s.agent} · ${basename(s.cwd)}` : '';
596
- const hb = $('#headBot'); if (hb) { hb.innerHTML = bot(sessionMood(s)); window.NioBot?.refresh(); }
603
+ const hb = $('#headBot'); if (hb) { hb.dataset.agent = s?.agent || ''; hb.innerHTML = bot(sessionMood(s)); window.NioBot?.refresh(); }
597
604
  if (!s) { bar.innerHTML = ''; return; }
598
605
  const ag = state.info?.agents?.find((a) => a.id === s.agent);
599
- bar.innerHTML = `<span class="chip"><span class="agent-tag" data-agent="${esc(s.agent)}">${esc(AGENT_NAMES[s.agent] || s.agent)}</span></span>
606
+ 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>
600
607
  ${ag?.noModes ? '' : `<button class="chip" data-act="mode" type="button">${esc(MODES[s.mode]?.label || s.mode)}</button>`}
601
608
  <span class="chip mono">${esc(basename(s.cwd))}</span>
602
609
  <button class="chip mono" data-act="model" type="button" title="Canvia de model">${esc(s.model || 'Model per defecte')}</button>
@@ -640,11 +647,11 @@ function eventNode(e) {
640
647
  const s = state.sessions.get(state.route.id);
641
648
  switch (e.kind) {
642
649
  case 'user': return el(`<div class="msg-user">${esc(e.text)}</div>`);
643
- case 'text': return el(`<div class="msg-ai ${e.open && s?.state === 'running' ? 'cursor' : ''}">${md(e.text)}</div>`);
650
+ case 'text': return el(`<div class="msg-ai ${e.open && s?.state === 'running' ? 'cursor' : ''}" data-seq="${e.seq}">${md(e.text)}${!e.open && voiceOn() ? `<button class="speak" type="button" data-speak aria-label="Escolta la resposta">${svg(ICON.speaker)}<span>Escolta</span></button>` : ''}</div>`);
644
651
  case 'tool': return el(`<details class="tool"><summary><span class="name">${esc(e.name)}</span><span class="arg">${esc(toolArg(e.input))}</span><span class="st">…</span></summary><pre>${esc(prettyInput(e.input))}</pre></details>`);
645
652
  case 'tool_result': return el(`<details class="tool"><summary><span class="name">${esc(e.name || 'Eina')}</span><span class="arg"></span><span class="st ${e.error ? 'err' : 'ok'}">${e.error ? '✕' : '✓'}</span></summary><pre class="out">${esc(e.output)}</pre></details>`);
646
653
  case 'approval': { const a = state.approvals.get(e.approval) || {}; return el(approvalCard({ ...a, approval: e.approval, tool: e.tool, input: e.input, state: e.state && e.state !== 'pending' ? e.state : a.state || e.state }, false)); }
647
- case 'error': return el(`<div class="ev-error">${esc(e.text)}</div>`);
654
+ case 'error': return el(`<div class="ev-error" role="alert"><b>Error</b><span>${esc(e.text)}</span></div>`);
648
655
  case 'info': return el(`<div class="ev-info">${esc(e.text)}</div>`);
649
656
  case 'result': {
650
657
  const bits = [];
@@ -664,10 +671,61 @@ function renderTyping() {
664
671
  const last = evs ? [...evs.values()].sort((a, b) => b.seq - a.seq).find((x) => x.kind !== 'status' && x.kind !== 'log') : null;
665
672
  const streaming = last && last.kind === 'text' && last.open;
666
673
  let t = $('.typing', feed);
667
- if (busy && !streaming) { if (!t) { feed.appendChild(el(`<div class="typing">${bot('work')}<span>${esc(AGENT_NAMES[s.agent] || 'L’agent')} està treballant…</span></div>`)); window.NioBot?.refresh(); } }
674
+ if (busy && !streaming) { if (!t) { feed.appendChild(el(`<div class="typing">${agentBot(s.agent, 'work')}<span>${esc(AGENT_NAMES[s.agent] || 'L’agent')} està treballant…</span></div>`)); window.NioBot?.refresh(); } }
668
675
  else if (t) t.remove();
669
676
  if (!busy) $$('.msg-ai.cursor', feed).forEach((n) => n.classList.remove('cursor'));
670
- const hb = $('#headBot .bot'); if (hb) hb.dataset.mood = sessionMood(s);
677
+ $$('.mode-bar .abot .bot, #headBot .bot').forEach((b) => { b.dataset.mood = sessionMood(s); });
678
+ }
679
+ /* ---------- Comandes amb «/» (les de nio i les de l'agent) */
680
+ const NIO_CMDS = [
681
+ { name: 'mode', hint: 'Canvia els permisos', args: [['llegir', 'Només llegir', 'l'], ['preguntar', 'Demana permís', 'p'], ['tot', 'Via lliure', 't']] },
682
+ { name: 'model', hint: 'Canvia de model' },
683
+ { name: 'stop', hint: 'Atura l’agent' },
684
+ { name: 'nova', hint: 'Nova sessió amb el mateix agent i carpeta' },
685
+ { name: 'git', hint: 'Git i GitHub: commit i PR' },
686
+ { name: 'nom', hint: 'Posa nom a la sessió', free: true },
687
+ { name: 'opcions', hint: 'Opcions de la sessió' },
688
+ { name: 'ajuda', hint: 'Llista de comandes' }
689
+ ];
690
+ const cmdCache = new Map();
691
+ async function loadCmds(id) {
692
+ if (cmdCache.has(id) || !machine()) return;
693
+ cmdCache.set(id, []);
694
+ try { cmdCache.set(id, await api(`/api/sessions/${id}/commands`)); } catch { /* sense comandes de l'agent */ }
695
+ }
696
+ const agentCmds = (id) => cmdCache.get(id) || [];
697
+ function pickMode(arg) {
698
+ const a = String(arg || '').trim().toLowerCase(); if (!a) return null;
699
+ const m = NIO_CMDS[0].args.find(([k, , al]) => k === a || al === a || k.startsWith(a) || (a === 'lliure' && k === 'tot'));
700
+ return m ? m[0] : null;
701
+ }
702
+ async function runCmd(s, name, arg) {
703
+ 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); } };
704
+ switch (name) {
705
+ 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}`); }
706
+ case 'model': return arg ? patch({ model: arg }, `Model: ${arg}`) : modelSheet(s);
707
+ case 'stop': try { await api(`/api/sessions/${s.id}/stop`, { method: 'POST' }); toast('Aturat'); } catch (e) { toast(e.message); } return;
708
+ case 'nova': return newSessionSheet({ agent: s.agent, cwd: s.cwd, mode: s.mode });
709
+ case 'git': return gitSheet(s.id);
710
+ case 'nom': return arg ? patch({ title: arg }, 'Nom desat') : toast('Escriu el nom: /nom Arregla els tests');
711
+ case 'opcions': return sessionOptions(s.id);
712
+ case 'ajuda': return helpCmdsSheet(s);
713
+ }
714
+ }
715
+ function helpCmdsSheet(s) {
716
+ const ac = agentCmds(s.id);
717
+ 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; });
718
+ }
719
+ // Llista de suggeriments segons el que hi ha escrit: «/», «/mo», «/mode l».
720
+ function slashItems(s, v) {
721
+ const m = v.match(/^\/([\w:.-]*)$/), a = v.match(/^\/mode\s+(\S*)$/i);
722
+ 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' })); }
723
+ if (!m) return [];
724
+ const q = m[1].toLowerCase();
725
+ const nio = NIO_CMDS.map((c) => ({ ...c, tag: 'nio' }));
726
+ const ag = agentCmds(s.id).map((c) => ({ name: c.name, hint: c.hint, tag: AGENT_NAMES[s.agent] || s.agent, agent: true }));
727
+ 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));
728
+ return [...starts, ...has].slice(0, 40).map((c) => ({ ...c, label: `/${c.name}` }));
671
729
  }
672
730
  function renderComposer() {
673
731
  const box = $('#composer'); if (!box) return;
@@ -675,17 +733,46 @@ function renderComposer() {
675
733
  const prev = $('textarea', box)?.value || '';
676
734
  if (box.dataset.busy === String(busy) && $('textarea', box)) return;
677
735
  box.dataset.busy = String(busy);
678
- box.innerHTML = `<div class="inner"><textarea rows="1" id="msg" placeholder="${busy ? 'L’agent està treballant…' : 'Escriu a l’agent…'}" enterkeyhint="send" aria-label="Missatge"></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>`;
736
+ if (s) loadCmds(s.id).then(() => { if (state.route.id === s.id) drawSlash(); });
737
+ 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>`;
679
738
  const ta = $('#msg'); ta.value = prev;
680
739
  const grow = () => autogrow(ta);
681
- grow(); ta.addEventListener('input', grow);
740
+ let items = [], sel = 0;
741
+ const pop = $('#slash');
742
+ function drawSlash() {
743
+ const cur = state.sessions.get(state.route.id); if (!cur || !pop.isConnected) return;
744
+ items = ta.value.includes('\n') ? [] : slashItems(cur, ta.value);
745
+ pop.hidden = !items.length; if (!items.length) return;
746
+ sel = Math.min(sel, items.length - 1);
747
+ 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('');
748
+ $('[aria-selected="true"]', pop)?.scrollIntoView({ block: 'nearest' });
749
+ }
750
+ const pick = (i) => {
751
+ const c = items[i]; if (!c) return; const cur = state.sessions.get(state.route.id);
752
+ if (c.run) { ta.value = ''; grow(); drawSlash(); return runCmd(cur, ...c.run); }
753
+ if (c.args || c.free || c.agent) { ta.value = `/${c.name} `; grow(); sel = 0; drawSlash(); ta.focus(); return; }
754
+ ta.value = ''; grow(); drawSlash(); runCmd(cur, c.name, '');
755
+ };
756
+ ta.addEventListener('input', () => { grow(); sel = 0; drawSlash(); });
757
+ pop.addEventListener('pointerdown', (e) => { const b = e.target.closest('[data-i]'); if (b) { e.preventDefault(); pick(+b.dataset.i); } });
682
758
  const send = async () => {
683
759
  const text = ta.value.trim(); if (!text || !s) return;
684
- ta.value = ''; grow();
760
+ const c = text.match(/^\/([\w:.-]+)(?:\s+([\s\S]*))?$/), nio = c && NIO_CMDS.find((x) => x.name === c[1].toLowerCase());
761
+ ta.value = ''; grow(); drawSlash();
762
+ if (nio) return runCmd(s, nio.name, (c[2] || '').trim());
685
763
  try { await api(`/api/sessions/${s.id}/message`, { method: 'POST', body: { text } }); }
686
764
  catch (e) { ta.value = text; grow(); toast(e.message, 4200); }
687
765
  };
688
- ta.addEventListener('keydown', (e) => { if (e.key === 'Enter' && !e.shiftKey && !isTouch && !e.isComposing) { e.preventDefault(); send(); } });
766
+ ta.addEventListener('keydown', (e) => {
767
+ if (items.length && !pop.hidden) {
768
+ if (e.key === 'ArrowDown') { e.preventDefault(); sel = (sel + 1) % items.length; return drawSlash(); }
769
+ if (e.key === 'ArrowUp') { e.preventDefault(); sel = (sel - 1 + items.length) % items.length; return drawSlash(); }
770
+ if (e.key === 'Tab' || (e.key === 'Enter' && !e.shiftKey && !isTouch && !e.isComposing && !/^\/\S+\s/.test(ta.value))) { e.preventDefault(); return pick(sel); }
771
+ if (e.key === 'Escape') { e.preventDefault(); pop.hidden = true; items = []; return; }
772
+ }
773
+ if (e.key === 'Enter' && !e.shiftKey && !isTouch && !e.isComposing) { e.preventDefault(); send(); }
774
+ });
775
+ grow(); drawSlash();
689
776
  $('#sendBtn').onclick = async () => { if (busy) { try { await api(`/api/sessions/${s.id}/stop`, { method: 'POST' }); } catch (e) { toast(e.message); } } else send(); };
690
777
  }
691
778
 
@@ -698,8 +785,11 @@ function openSheet(html, onMount, cls) {
698
785
  const closeSheet = () => { const d = $('#sheet'); if (d.open) d.close(); };
699
786
  $('#sheet').addEventListener('click', (e) => { if (e.target.id === 'sheet') closeSheet(); });
700
787
 
788
+ // Claude Code no deixa «Via lliure» quan nio corre com a root (típic en un VPS).
789
+ const modeBlocked = (agentId, mode) => agentId === 'claude' && mode === 'tot' && state.info?.user === 'root';
701
790
  function modeHelp(agentId, mode) {
702
791
  const a = state.info?.agents?.find((x) => x.id === agentId);
792
+ if (modeBlocked(agentId, mode)) return 'No disponible: Claude Code no ho permet quan s’executa com a root. Fes servir «Demana permís» o engega nio amb un usuari normal.';
703
793
  if (mode === 'llegir') return 'Llegeix el projecte i et proposa canvis, però no toca cap fitxer.';
704
794
  if (mode === 'tot') return 'Treballa sense preguntar. Fes-lo servir només en carpetes on confiïs plenament.';
705
795
  if (a?.approvals) return 'Llegeix sol. Abans d’editar un fitxer o d’executar una ordre, et demana permís aquí.';
@@ -782,10 +872,11 @@ async function newSessionSheet(preset = {}) {
782
872
  },
783
873
  () => {
784
874
  const ag = agentOf();
875
+ if (modeBlocked(sel.agent, sel.mode)) sel.mode = 'preguntar';
785
876
  const modelHtml = sel.agent === 'ollama'
786
877
  ? `<label class="f"><span>Model</span><select id="modelSel">${(models || []).map((m) => `<option ${m.name === sel.model ? 'selected' : ''}>${esc(m.name)}</option>`).join('') || `<option value="">${models == null ? 'Carregant…' : 'Cap model (ollama pull qwen3:4b)'}</option>`}</select></label>`
787
878
  : `<label class="f"><span>Model <span class="muted">(opcional)</span></span><input type="text" id="modelIn" value="${esc(sel.model)}" placeholder="El que l’agent faci servir per defecte" autocapitalize="off" spellcheck="false"></label>`;
788
- return `<p class="wiz-q">Quins permisos té?</p>${ag?.noModes ? '<p class="muted small">Aquest agent no té modes de permisos.</p>' : `<div class="list">${Object.entries(MODES).map(([k, m]) => `<button class="card" type="button" data-mode="${k}" aria-pressed="${k === sel.mode}"><b>${m.label}</b><div class="muted small" style="margin-top:4px">${esc(modeHelp(sel.agent, k))}</div></button>`).join('')}</div>`}${modelHtml}`;
879
+ return `<p class="wiz-q">Quins permisos té?</p>${ag?.noModes ? '<p class="muted small">Aquest agent no té modes de permisos.</p>' : `<div class="list">${Object.entries(MODES).map(([k, m]) => `<button class="card" type="button" data-mode="${k}" aria-pressed="${k === sel.mode}" ${modeBlocked(sel.agent, k) ? 'disabled' : ''}><b>${m.label}</b><div class="muted small" style="margin-top:4px">${esc(modeHelp(sel.agent, k))}</div></button>`).join('')}</div>`}${modelHtml}`;
789
880
  },
790
881
  () => `<p class="wiz-q">Què ha de fer?</p>
791
882
  <dl class="kv"><dt>Agent</dt><dd>${esc(AGENT_NAMES[sel.agent] || sel.agent)}</dd><dt>Carpeta</dt><dd class="mono">${esc(sel.cwd)}</dd>${agentOf()?.noModes ? '' : `<dt>Permisos</dt><dd>${esc(MODES[sel.mode].label)}</dd>`}${sel.model ? `<dt>Model</dt><dd class="mono">${esc(sel.model)}</dd>` : ''}</dl>
@@ -850,7 +941,7 @@ async function modelSheet(s) {
850
941
  });
851
942
  }
852
943
  function modeSheet(s) {
853
- openSheet(`<h2>Permisos</h2><div class="list">${Object.entries(MODES).map(([k, m]) => `<button class="card" type="button" data-m="${k}" style="${k === s.mode ? 'border-color:var(--fg);box-shadow:inset 0 0 0 1px var(--fg)' : ''}"><b>${m.label}</b><div class="muted small" style="margin-top:4px">${esc(modeHelp(s.agent, k))}</div></button>`).join('')}</div><p class="muted small">El canvi s’aplica a partir del pròxim missatge.</p>`, (sh) => {
944
+ openSheet(`<h2>Permisos</h2><div class="list">${Object.entries(MODES).map(([k, m]) => `<button class="card" type="button" data-m="${k}" ${modeBlocked(s.agent, k) ? 'disabled' : ''} style="${k === s.mode ? 'border-color:var(--fg);box-shadow:inset 0 0 0 1px var(--fg)' : ''}"><b>${m.label}</b><div class="muted small" style="margin-top:4px">${esc(modeHelp(s.agent, k))}</div></button>`).join('')}</div><p class="muted small">El canvi s’aplica a partir del pròxim missatge.</p>`, (sh) => {
854
945
  $$('[data-m]', sh).forEach((b) => (b.onclick = async () => {
855
946
  try { const sum = await api(`/api/sessions/${s.id}`, { method: 'PATCH', body: { mode: b.dataset.m } }); state.sessions.set(s.id, sum); closeSheet(); renderSessionHead(); }
856
947
  catch (e) { toast(e.message); }
@@ -1140,9 +1231,81 @@ async function sendLocal() {
1140
1231
  local.messages.push({ role: 'assistant', content: stripThink(acc) || '…' }); local.busy = false; local.stopped = false; setLocalBusy(false);
1141
1232
  }
1142
1233
 
1234
+ /* ---------------------------------------------------------------- veu (ElevenLabs) */
1235
+ // La clau viu només en aquest navegador; les crides van directes a ElevenLabs, sense passar per nio.
1236
+ const VOICE_DEFAULT = { key: '', voice: 'JBFqnCBsd6RMkjVDRZzb', model: 'eleven_v3' };
1237
+ const voiceCfg = () => ({ ...VOICE_DEFAULT, ...LS.get('nio.voice', {}) });
1238
+ const voiceOn = () => !!voiceCfg().key;
1239
+ async function elevenlabs(path, init = {}) {
1240
+ const res = await fetch(`https://api.elevenlabs.io${path}`, { ...init, headers: { 'xi-api-key': voiceCfg().key, ...(init.headers || {}) } });
1241
+ if (res.ok) return res;
1242
+ let why = ''; try { const j = await res.json(); why = j.detail?.message || (typeof j.detail === 'string' ? j.detail : ''); } catch { /* sense cos */ }
1243
+ throw new Error(res.status === 401 ? 'La clau d’ElevenLabs no és vàlida.' : res.status === 429 ? 'ElevenLabs diu que has superat el límit. Prova-ho d’aquí una estona.' : `ElevenLabs: ${why || `error ${res.status}`}`);
1244
+ }
1245
+ const speech = { audio: null, btn: null, cache: new Map() };
1246
+ const speechText = (t) => String(t).replace(/```[\s\S]*?```/g, ' (bloc de codi) ').replace(/`([^`]+)`/g, '$1').replace(/\*\*([^*]+)\*\*/g, '$1').replace(/\[([^\]]+)\]\([^)]+\)/g, '$1').replace(/^#{1,4}\s+/gm, '').replace(/^\s*[-*•]\s+/gm, '').trim().slice(0, 4500);
1247
+ function stopSpeech() { speech.audio?.pause(); speech.audio = null; speech.btn?.classList.remove('on', 'wait'); speech.btn = null; }
1248
+ async function speak(btn) {
1249
+ if (speech.btn === btn) return stopSpeech();
1250
+ stopSpeech();
1251
+ const ev = state.events.get(state.route.id)?.get(Number(btn.closest('[data-seq]')?.dataset.seq)), text = speechText(ev?.text || '');
1252
+ if (!text) return;
1253
+ speech.btn = btn; btn.classList.add('wait');
1254
+ try {
1255
+ const c = voiceCfg(), k = `${c.voice}|${c.model}|${text}`; let url = speech.cache.get(k);
1256
+ if (!url) {
1257
+ const res = await elevenlabs(`/v1/text-to-speech/${encodeURIComponent(c.voice)}?output_format=mp3_44100_64`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ text, model_id: c.model }) });
1258
+ url = URL.createObjectURL(await res.blob()); speech.cache.set(k, url);
1259
+ }
1260
+ if (speech.btn !== btn) return;
1261
+ const a = new Audio(url); speech.audio = a; a.onended = stopSpeech; await a.play();
1262
+ btn.classList.remove('wait'); btn.classList.add('on');
1263
+ } catch (e) { stopSpeech(); toast(e.message, 4500); }
1264
+ }
1265
+ const mic = { rec: null, chunks: [], stream: null };
1266
+ async function toggleMic() {
1267
+ if (!voiceOn()) return voiceSheet();
1268
+ if (mic.rec) return mic.rec.stop();
1269
+ if (!navigator.mediaDevices?.getUserMedia || !window.MediaRecorder) return toast('Aquest navegador no pot gravar àudio.');
1270
+ try { mic.stream = await navigator.mediaDevices.getUserMedia({ audio: true }); } catch { return toast('No tinc permís per al micròfon. Activa’l als ajustos del navegador.', 4500); }
1271
+ const rec = new MediaRecorder(mic.stream); mic.rec = rec; mic.chunks = [];
1272
+ rec.ondataavailable = (e) => { if (e.data.size) mic.chunks.push(e.data); };
1273
+ rec.onstop = async () => {
1274
+ mic.stream.getTracks().forEach((t) => t.stop()); mic.rec = null;
1275
+ const blob = new Blob(mic.chunks, { type: rec.mimeType || 'audio/webm' });
1276
+ $('#micBtn')?.classList.remove('rec'); $('#micBtn')?.classList.add('wait');
1277
+ try {
1278
+ if (blob.size < 1500) throw new Error('No he sentit res. Prova-ho una altra vegada.');
1279
+ const fd = new FormData(); fd.append('model_id', 'scribe_v1'); fd.append('file', blob, 'veu.webm');
1280
+ const r = await (await elevenlabs('/v1/speech-to-text', { method: 'POST', body: fd })).json(), ta = $('#msg');
1281
+ if (!r.text?.trim()) toast('No he entès res.');
1282
+ else if (ta) { ta.value = `${ta.value ? `${ta.value} ` : ''}${r.text.trim()}`; ta.dispatchEvent(new Event('input')); ta.focus(); }
1283
+ } catch (e) { toast(e.message, 4500); }
1284
+ $('#micBtn')?.classList.remove('wait');
1285
+ };
1286
+ rec.start(); $('#micBtn')?.classList.add('rec');
1287
+ }
1288
+ function voicePanel() {
1289
+ return `<section class="panel" style="margin-top:28px"><header><h2>Veu</h2>${voiceOn() ? '<span class="chip ok"><i class="dot on"></i>ElevenLabs</span>' : ''}</header><div class="body stack"><p class="small">Dicta els missatges amb el micròfon i escolta les respostes de l’agent, amb ElevenLabs.</p><div class="row"><button class="btn ink" data-act="voice" type="button">${voiceOn() ? 'Canvia la configuració' : 'Configura la veu'}</button></div></div></section>`;
1290
+ }
1291
+ function voiceSheet() {
1292
+ const c = voiceCfg();
1293
+ openSheet(`<h2>Veu amb ElevenLabs</h2><p class="muted small">Dicta amb el micròfon i escolta les respostes. La clau es queda només en aquest navegador: les crides van directes a ElevenLabs, nio no les veu.</p>
1294
+ <form class="stack" id="voiceForm" style="margin-top:14px">
1295
+ <label class="f"><span>Clau d’API</span><input type="password" id="vKey" value="${esc(c.key)}" placeholder="sk_…" autocomplete="off" autocapitalize="off" spellcheck="false"></label>
1296
+ <label class="f"><span>Identificador de la veu</span><input type="text" id="vVoice" value="${esc(c.voice)}" autocomplete="off" autocapitalize="off" spellcheck="false"></label>
1297
+ <label class="f"><span>Model</span><select id="vModel"><option value="eleven_v3" ${c.model === 'eleven_v3' ? 'selected' : ''}>v3 (inclou català)</option><option value="eleven_multilingual_v2" ${c.model === 'eleven_multilingual_v2' ? 'selected' : ''}>Multilingual v2</option></select></label>
1298
+ <div class="row between sheet-foot"><button class="btn ghost" type="button" id="vOff" ${c.key ? '' : 'disabled'}>Treu la clau</button><button class="btn ink" type="submit">Desa</button></div>
1299
+ </form>`, (sh) => {
1300
+ const save = (v) => { LS.set('nio.voice', v); closeSheet(); if (state.route.name === 'session') renderSession(); else render(); };
1301
+ $('#voiceForm', sh).onsubmit = (e) => { e.preventDefault(); const key = $('#vKey', sh).value.trim(); if (!key) return toast('Enganxa la clau d’API.'); save({ key, voice: $('#vVoice', sh).value.trim() || VOICE_DEFAULT.voice, model: $('#vModel', sh).value }); toast('Veu activada'); };
1302
+ $('#vOff', sh).onclick = () => { save({ ...c, key: '' }); toast('Clau eliminada'); };
1303
+ });
1304
+ }
1305
+
1143
1306
  /* ---------------------------------------------------------------- accions */
1144
1307
  document.addEventListener('click', async (e) => {
1145
- const a = e.target.closest('[data-act], [data-copy], [data-decide], [data-machine], [data-forget], [data-reconnect], [data-lm]');
1308
+ const a = e.target.closest('[data-act], [data-speak], [data-mic], [data-copy], [data-decide], [data-machine], [data-forget], [data-reconnect], [data-lm]');
1146
1309
  if (!a) return;
1147
1310
  if (a.dataset.copy) {
1148
1311
  try { await navigator.clipboard.writeText(a.dataset.copy); } catch { return toast(a.dataset.copy); }
@@ -1153,6 +1316,8 @@ document.addEventListener('click', async (e) => {
1153
1316
  setTimeout(() => { a.innerHTML = html; a.classList.remove('copied'); delete a.dataset.copied; }, 1700);
1154
1317
  return;
1155
1318
  }
1319
+ if (a.dataset.speak != null) return speak(a);
1320
+ if (a.dataset.mic != null) return toggleMic();
1156
1321
  if (a.dataset.decide) return decide(a.closest('[data-approval]'), a.dataset.decide);
1157
1322
  if (a.dataset.machine != null) return switchMachine(Number(a.dataset.machine));
1158
1323
  if (a.dataset.reconnect != null) { const m = store.machines[Number(a.dataset.reconnect)]; return addComputerSheet('vps', { target: m.via, auto: true }); }
@@ -1185,6 +1350,7 @@ document.addEventListener('click', async (e) => {
1185
1350
  case 'lm-clear': local.messages = []; return render();
1186
1351
  case 'palette': return palette();
1187
1352
  case 'tour': return tourStart();
1353
+ case 'voice': return voiceSheet();
1188
1354
  }
1189
1355
  });
1190
1356
  document.addEventListener('keydown', (e) => { const t = e.target; if ((e.key === 'Enter' || e.key === ' ') && t.classList?.contains('switch') && t.dataset.act) { e.preventDefault(); t.click(); } });
@@ -1205,6 +1371,7 @@ function commands() {
1205
1371
  c.push({ label: 'Afegeix un ordinador', hint: 'Crea', run: () => addComputerSheet() });
1206
1372
  [...state.sessions.values()].slice(0, 20).forEach((s) => c.push({ label: s.title || 'Sessió nova', hint: AGENT_NAMES[s.agent] || s.agent, run: () => go(`#/s/${s.id}`) }));
1207
1373
  c.push({ label: 'Com es fa (ajuda)', hint: 'Ajuda', run: () => { go('#/ordinadors'); setTimeout(() => $('#help')?.scrollIntoView({ block: 'start' }), 60); } });
1374
+ c.push({ label: 'Configura la veu (ElevenLabs)', hint: 'Ajustos', run: voiceSheet });
1208
1375
  c.push({ label: 'Fes el tutorial', hint: 'Ajuda', run: tourStart });
1209
1376
  return c;
1210
1377
  }
package/web/app/style.css CHANGED
@@ -104,7 +104,7 @@ kbd { font: 600 11px var(--font); padding: 1px 6px; border: 1px solid var(--bord
104
104
  .icon-btn { position: relative; display: grid; place-items: center; width: 40px; height: 40px; border: 0; border-radius: 12px; background: none; color: var(--fg); }
105
105
  .icon-btn:hover { background: color-mix(in srgb, var(--bg-2) 80%, transparent); }
106
106
  .icon-btn .badge { position: absolute; top: 3px; right: 1px; min-width: 18px; height: 18px; font-size: 10px; }
107
- input[type="text"], input[type="url"], input[type="search"], select, textarea.field { width: 100%; min-width: 0; height: 42px; padding: 0 12px; border: 1px solid var(--border); border-radius: 10px; background: var(--white); outline: none; }
107
+ input[type="text"], input[type="password"], input[type="url"], input[type="search"], select, textarea.field { width: 100%; min-width: 0; height: 42px; padding: 0 12px; border: 1px solid var(--border); border-radius: 10px; background: var(--white); outline: none; }
108
108
  textarea.field { height: auto; padding: 10px 12px; resize: vertical; line-height: 1.45; }
109
109
  input:focus, select:focus, textarea:focus { border-color: var(--fg); }
110
110
  select { appearance: none; -webkit-appearance: none; padding-right: 40px; cursor: pointer; background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%2369615c' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E"); background-repeat: no-repeat; background-position: right 12px center; background-size: 16px 16px; }
@@ -134,8 +134,13 @@ button.chip:hover { border-color: var(--fg); }
134
134
  @keyframes pulse { 50% { opacity: .3; } }
135
135
  .agent-tag { display: inline-flex; align-items: center; gap: 7px; font-size: 13px; font-weight: 600; }
136
136
  .agent-tag::before { content: ""; width: 8px; height: 8px; border-radius: 3px; background: var(--c, var(--fg-muted)); }
137
- [data-agent="claude"] { --c: var(--accent); } [data-agent="codex"] { --c: var(--moss); } [data-agent="grok"] { --c: var(--fg); }
138
- [data-agent="antigravity"] { --c: var(--blue); } [data-agent="ollama"] { --c: #b08a5a; } [data-agent="demo"] { --c: var(--lemon); } [data-agent="browser"] { --c: #8a5cf6; }
137
+ [data-agent="claude"] { --c: #e8602c; } [data-agent="codex"] { --c: #2b5bd8; } [data-agent="grok"] { --c: var(--fg); }
138
+ [data-agent="antigravity"] { --c: #0f9d8a; } [data-agent="ollama"] { --c: #b08a5a; } [data-agent="demo"] { --c: var(--lemon); } [data-agent="browser"] { --c: #8a5cf6; }
139
+ .agent-tag.nosq::before { display: none; }
140
+ .abot { display: inline-flex; color: var(--c, var(--fg)); }
141
+ .abot .bot { --s: 20px; --bw: .15; }
142
+ .chat-head .abot .bot { --s: 30px; --bw: .14; }
143
+ .typing .abot .bot { --s: 22px; --bw: .15; }
139
144
  .empty { padding: 48px 20px; text-align: center; color: var(--fg-muted); display: grid; justify-items: center; gap: 8px; }
140
145
  .empty .bot { --s: 56px; --bw: .13; color: var(--fg); margin-bottom: 8px; }
141
146
  .empty h2 { font-size: 20px; font-weight: 500; letter-spacing: -.04em; color: var(--fg); }
@@ -175,6 +180,7 @@ button.chip:hover { border-color: var(--fg); }
175
180
 
176
181
  /* ---------- Sessions ---------- */
177
182
  .session-card .title { font-size: 16px; font-weight: 500; letter-spacing: -.02em; margin-top: 8px; }
183
+ .session-card .last.err { color: var(--red); }
178
184
  .session-card .last { color: var(--fg-muted); font-size: 14px; margin-top: 4px; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
179
185
  .session-card .meta { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 10px; font-size: 12px; color: var(--fg-muted); }
180
186
  .fab { position: fixed; right: 24px; bottom: calc(24px + var(--safe-b)); z-index: 15; display: inline-flex; align-items: center; gap: 8px; height: 50px; padding: 0 20px 0 16px; border: 0; border-radius: 16px; background: var(--fg); color: var(--bg); font-weight: 600; box-shadow: 0 14px 30px -14px color-mix(in srgb, var(--fg) 70%, transparent); transition: transform .15s; }
@@ -183,7 +189,6 @@ button.chip:hover { border-color: var(--fg); }
183
189
  /* ---------- Xat de sessió ---------- */
184
190
  .chat { display: flex; flex-direction: column; height: 100vh; height: 100dvh; }
185
191
  .chat-head { display: flex; align-items: center; gap: 12px; padding: 14px 24px; border-bottom: 1px solid var(--border); }
186
- .chat-head .bot { --s: 30px; --bw: .14; }
187
192
  .chat-head h1 { font-size: 17px; font-weight: 600; letter-spacing: -.03em; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
188
193
  .chat-head .sub { font-size: 12px; color: var(--fg-muted); }
189
194
  .mode-bar { display: flex; gap: 6px; padding: 10px 24px; overflow-x: auto; scrollbar-width: none; border-bottom: 1px solid color-mix(in srgb, var(--border) 60%, transparent); }
@@ -210,12 +215,11 @@ button.chip:hover { border-color: var(--fg); }
210
215
  .tool pre { margin: 0 12px 12px; padding: 10px 12px; max-height: 320px; overflow: auto; border-radius: 8px; background: var(--code); font: 12px/1.5 var(--mono); white-space: pre-wrap; overflow-wrap: anywhere; }
211
216
  .ev-info, .ev-result { text-align: center; font-size: 12px; color: var(--fg-muted); }
212
217
  .ev-result { display: flex; justify-content: center; gap: 6px; }
213
- .ev-error { padding: 10px 14px; border-radius: 10px; border: 1px solid color-mix(in srgb, var(--red) 35%, transparent); background: color-mix(in srgb, var(--red) 6%, var(--white)); color: var(--red); font-size: 14px; white-space: pre-wrap; }
218
+ .ev-error { display: grid; gap: 2px; padding: 10px 14px; border-radius: 10px; border: 1px solid color-mix(in srgb, var(--red) 35%, transparent); background: color-mix(in srgb, var(--red) 6%, var(--white)); color: var(--red); font-size: 14px; white-space: pre-wrap; }
214
219
  .ev-logs { font-size: 12px; color: var(--fg-muted); }
215
220
  .ev-logs summary { cursor: pointer; }
216
221
  .ev-logs pre { margin-top: 6px; padding: 10px; border-radius: 8px; background: var(--code); font: 11px/1.5 var(--mono); max-height: 220px; overflow: auto; white-space: pre-wrap; }
217
222
  .typing { display: flex; align-items: center; gap: 10px; color: var(--fg-muted); font-size: 13px; }
218
- .typing .bot { --s: 22px; --bw: .15; color: var(--fg); }
219
223
  @keyframes rise { from { opacity: 0; transform: translateY(6px); } }
220
224
 
221
225
  .approval { padding: 16px; border-radius: var(--radius); border: 1.5px solid var(--accent); background: var(--white); display: grid; gap: 10px; animation: rise .25s var(--ease); }
@@ -345,7 +349,7 @@ body.board-mode .view-enter { animation: none; }
345
349
  body.chat-mode .mbar { position: fixed; left: 0; right: 0; transform: translateY(var(--vvt, 0px)); }
346
350
  body.chat-mode .chat { position: fixed; left: 0; right: 0; top: calc(56px + var(--safe-t) + var(--vvt, 0px)); height: calc(var(--vvh, 100dvh) - 56px - var(--safe-t)); }
347
351
  body.chat-mode.kb .composer { padding-bottom: 10px; }
348
- .composer textarea, input[type="text"], input[type="url"], input[type="search"], select, textarea.field { font-size: 16px; }
352
+ .composer textarea, input[type="text"], input[type="password"], input[type="url"], input[type="search"], select, textarea.field { font-size: 16px; }
349
353
  .chat-head { display: none; }
350
354
  .mode-bar { padding: 10px 16px; }
351
355
  .feed { padding: 18px 16px 24px; }
@@ -449,7 +453,8 @@ video.scan { width: 100%; aspect-ratio: 1; object-fit: cover; border-radius: 14p
449
453
  .burger { display: none; }
450
454
  .tabbar { display: flex; position: fixed; left: 0; right: 0; bottom: 0; z-index: 30; padding: 6px 8px calc(6px + var(--safe-b)); background: color-mix(in srgb, var(--bg) 94%, transparent); -webkit-backdrop-filter: blur(14px); backdrop-filter: blur(14px); border-top: 1px solid var(--border); }
451
455
  .tabbar > * { flex: 1; position: relative; display: grid; justify-items: center; gap: 2px; padding: 6px 0; border: 0; background: none; border-radius: 10px; font-size: 11px; font-weight: 600; color: var(--fg-muted); text-decoration: none; }
452
- .tabbar [aria-current="page"] { color: var(--fg); }
456
+ .tabbar [aria-current="page"] { color: var(--accent); }
457
+ .tabbar [aria-current="page"]::before { content: ""; position: absolute; top: 0; left: 50%; width: 22px; height: 3px; margin-left: -11px; border-radius: 0 0 3px 3px; background: var(--accent); }
453
458
  .tabbar .badge { position: absolute; top: 2px; left: calc(50% + 6px); min-width: 16px; height: 16px; padding: 0 4px; border-radius: 8px; background: var(--accent); color: #fff; font-size: 10px; line-height: 16px; text-align: center; }
454
459
  body:not(.no-tabs):not(.kb) .main { padding-bottom: calc(64px + var(--safe-b)); }
455
460
  body.no-tabs .tabbar, body.kb .tabbar { display: none; }
@@ -469,3 +474,24 @@ video.scan { width: 100%; aspect-ratio: 1; object-fit: cover; border-radius: 14p
469
474
  .help-body b { color: var(--fg); font-weight: 600; }
470
475
  .empty .stepper { text-align: left; max-width: 640px; margin: 16px auto 18px; }
471
476
  a.kbd { text-decoration: none; color: inherit; }
477
+
478
+ /* Comandes amb «/» */
479
+ .composer { position: relative; }
480
+ .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; }
481
+ .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; }
482
+ .slash button[aria-selected="true"] { background: var(--bg-2); }
483
+ .slash .n { font-weight: 600; }
484
+ .slash .h { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--fg-muted); font-size: 13px; }
485
+ .slash .t { font-size: 11px; padding: 1px 7px; border-radius: 6px; background: var(--bg-2); color: var(--fg-muted); }
486
+ .slash .t.ag { background: color-mix(in srgb, var(--lemon) 60%, var(--bg)); color: var(--fg); }
487
+ @media (max-width: 820px) { .slash { left: 12px; right: 12px; } .slash .h { grid-column: 1 / -1; grid-row: 2; } }
488
+ /* ---------- Veu ---------- */
489
+ .ev-error b { font-size: 12px; letter-spacing: .02em; text-transform: uppercase; }
490
+ .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; }
491
+ .speak:hover { color: var(--fg); border-color: var(--fg); }
492
+ .speak .ico { width: 15px; height: 15px; }
493
+ .speak.wait { opacity: .6; } .speak.on { color: var(--accent); border-color: var(--accent); }
494
+ .composer .mic { flex: none; display: grid; place-items: center; width: 46px; height: 46px; border: 1px solid var(--border); border-radius: 14px; background: var(--white); color: var(--fg-muted); }
495
+ .composer .mic:hover { color: var(--fg); border-color: var(--fg); }
496
+ .composer .mic.rec { background: var(--red); border-color: var(--red); color: #fff; animation: pulse 1.1s ease-in-out infinite; }
497
+ .composer .mic.wait { opacity: .5; }
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-v3';
2
+ const SHELL = 'nio-shell-v5';
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) => {