sanook-cli 0.5.7 → 0.5.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,9 +4,15 @@ const I18N = {
4
4
  tagline: 'Configure models, sessions, MCP, gateway, and your second brain',
5
5
  nav: {
6
6
  home: 'Home',
7
+ terminal: 'Terminal',
7
8
  chat: 'Chat',
8
9
  models: 'Models',
9
10
  sessions: 'Sessions',
11
+ skills: 'Skills',
12
+ memory: 'Memory',
13
+ persona: 'Persona',
14
+ usage: 'Usage',
15
+ selfimprove: 'Self-improve',
10
16
  files: 'Files',
11
17
  logs: 'Logs',
12
18
  cron: 'Cron',
@@ -14,7 +20,25 @@ const I18N = {
14
20
  config: 'Config',
15
21
  mcp: 'MCP',
16
22
  brain: 'Brain',
23
+ install: 'Install',
17
24
  },
25
+ terminal: {
26
+ title: 'Web terminal',
27
+ hint: 'Run Sanook AI right here — streams text, tools, 🧠 memory and ✨ skills like the real REPL.',
28
+ agent: 'Agent console',
29
+ shell: 'Raw shell',
30
+ run: 'Run',
31
+ stop: 'Stop',
32
+ placeholder: 'Type a prompt and press Enter (Shift+Enter = newline)…',
33
+ shellOff: 'Raw shell is disabled. Install optional deps to enable: npm i node-pty ws',
34
+ thinking: 'thinking…',
35
+ },
36
+ skills: { title: 'Skills', empty: 'No skills yet — Sanook will auto-create them from repeated tasks.', auto: 'auto', when: 'When to use' },
37
+ memory: { title: 'Memory', empty: 'No remembered facts yet. Ask Sanook to remember something.', brain: 'Also synced to second brain', importance: 'importance' },
38
+ persona: { title: 'Persona', empty: 'No persona yet — run sanook persona in your terminal.', edit: 'Update in terminal', profile: 'Profile path', rows: 'Your answers' },
39
+ usage: { title: 'Usage & cost', empty: 'No usage recorded yet.', turns: 'turns', tokens: 'tokens', cost: 'cost', daily: 'Daily breakdown' },
40
+ selfimprove: { title: 'Self-improvement', empty: 'No recurring tasks detected yet.', enabled: 'enabled', disabled: 'disabled', threshold: 'threshold', repeats: 'repeats', skill: 'skill' },
41
+ install: { title: 'Install Sanook CLI', ready: 'Ready', soon: 'Needs infra' },
18
42
  home: {
19
43
  title: 'System status',
20
44
  cliVersion: 'CLI version',
@@ -42,9 +66,15 @@ const I18N = {
42
66
  tagline: 'จัดการ model, session, MCP, gateway และ second brain',
43
67
  nav: {
44
68
  home: 'หน้าแรก',
69
+ terminal: 'เทอร์มินอล',
45
70
  chat: 'Chat',
46
71
  models: 'Models',
47
72
  sessions: 'Sessions',
73
+ skills: 'Skills',
74
+ memory: 'Memory',
75
+ persona: 'Persona',
76
+ usage: 'การใช้งาน',
77
+ selfimprove: 'เรียนรู้เอง',
48
78
  files: 'Files',
49
79
  logs: 'Logs',
50
80
  cron: 'Cron',
@@ -52,7 +82,25 @@ const I18N = {
52
82
  config: 'Config',
53
83
  mcp: 'MCP',
54
84
  brain: 'Brain',
85
+ install: 'ติดตั้ง',
86
+ },
87
+ terminal: {
88
+ title: 'เทอร์มินอลในเว็บ',
89
+ hint: 'รัน Sanook AI ได้เลยในเว็บ — สตรีมข้อความ, tool, 🧠 ความจำ และ ✨ skill เหมือน REPL จริง',
90
+ agent: 'Agent console',
91
+ shell: 'Raw shell',
92
+ run: 'รัน',
93
+ stop: 'หยุด',
94
+ placeholder: 'พิมพ์คำสั่งแล้วกด Enter (Shift+Enter = ขึ้นบรรทัดใหม่)…',
95
+ shellOff: 'Raw shell ปิดอยู่ — ติดตั้ง dependency เสริมเพื่อเปิด: npm i node-pty ws',
96
+ thinking: 'กำลังคิด…',
55
97
  },
98
+ skills: { title: 'Skills', empty: 'ยังไม่มี skill — Sanook จะสร้างให้อัตโนมัติจากงานที่ทำซ้ำ', auto: 'อัตโนมัติ', when: 'ใช้เมื่อ' },
99
+ memory: { title: 'ความจำ', empty: 'ยังไม่มีสิ่งที่จำไว้ — ลองสั่งให้ Sanook จำอะไรดู', brain: 'sync เข้า second brain ด้วย', importance: 'ความสำคัญ' },
100
+ persona: { title: 'Persona — โปรไฟล์เจ้าของ', empty: 'ยังไม่มี persona — รัน sanook persona ใน terminal', edit: 'อัปเดตใน terminal', profile: 'ไฟล์โปรไฟล์', rows: 'คำตอบของคุณ' },
101
+ usage: { title: 'การใช้งาน & ค่าใช้จ่าย', empty: 'ยังไม่มีข้อมูลการใช้งาน', turns: 'turn', tokens: 'tokens', cost: 'ค่าใช้จ่าย', daily: 'แยกตามวัน' },
102
+ selfimprove: { title: 'การเรียนรู้เอง (Self-improvement)', empty: 'ยังไม่เจองานที่ทำซ้ำ', enabled: 'เปิด', disabled: 'ปิด', threshold: 'เกณฑ์', repeats: 'ครั้ง', skill: 'skill' },
103
+ install: { title: 'ติดตั้ง Sanook CLI', ready: 'พร้อมใช้', soon: 'ต้องตั้ง infra' },
56
104
  home: {
57
105
  title: 'สถานะระบบ',
58
106
  cliVersion: 'เวอร์ชัน CLI',
@@ -76,9 +124,15 @@ const I18N = {
76
124
 
77
125
  const routes = [
78
126
  { id: 'home', path: '#/' },
127
+ { id: 'terminal', path: '#/terminal' },
79
128
  { id: 'chat', path: '#/chat' },
80
129
  { id: 'models', path: '#/models' },
81
130
  { id: 'sessions', path: '#/sessions' },
131
+ { id: 'skills', path: '#/skills' },
132
+ { id: 'memory', path: '#/memory' },
133
+ { id: 'persona', path: '#/persona' },
134
+ { id: 'usage', path: '#/usage' },
135
+ { id: 'selfimprove', path: '#/selfimprove' },
82
136
  { id: 'files', path: '#/files' },
83
137
  { id: 'logs', path: '#/logs' },
84
138
  { id: 'cron', path: '#/cron' },
@@ -86,9 +140,17 @@ const routes = [
86
140
  { id: 'config', path: '#/config' },
87
141
  { id: 'mcp', path: '#/mcp' },
88
142
  { id: 'brain', path: '#/brain' },
143
+ { id: 'install', path: '#/install' },
89
144
  ];
90
145
 
146
+ function escapeHtml(s) {
147
+ return String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]);
148
+ }
149
+
91
150
  let filesPath = '';
151
+ let termSessionId = localStorage.getItem('sanook-term-session') || `web-${Math.random().toString(36).slice(2, 10)}`;
152
+ localStorage.setItem('sanook-term-session', termSessionId);
153
+ let termAbort = null;
92
154
 
93
155
  function locale() {
94
156
  return localStorage.getItem('sanook-dashboard-locale') || 'en';
@@ -239,6 +301,318 @@ async function renderBrain(page) {
239
301
  <p>${brainPath ?? `<span class="hint">${t('brain.empty')}</span>`}</p></div>`;
240
302
  }
241
303
 
304
+ async function renderSkills(page) {
305
+ const { skills } = await api('/api/skills');
306
+ if (!skills?.length) {
307
+ page.innerHTML = `<div class="card"><h2>${t('skills.title')}</h2><p class="hint">${t('skills.empty')}</p></div>`;
308
+ return;
309
+ }
310
+ const cards = skills
311
+ .map((s) => `<div class="card" style="margin-bottom:10px;">
312
+ <h3 style="margin:0 0 4px;">${escapeHtml(s.name)} ${s.auto ? `<span class="pill" style="background:#1e3a2f;color:#7ee0a8;">✨ ${t('skills.auto')}</span>` : ''}</h3>
313
+ <p style="margin:0 0 6px;">${escapeHtml(s.description)}</p>
314
+ ${s.whenToUse ? `<p class="hint" style="margin:0;">${t('skills.when')}: ${escapeHtml(s.whenToUse)}</p>` : ''}
315
+ </div>`)
316
+ .join('');
317
+ page.innerHTML = `<h2 style="margin:0 0 12px;">${t('skills.title')} <span class="pill">${skills.length}</span></h2>${cards}`;
318
+ }
319
+
320
+ async function renderMemory(page) {
321
+ const { facts, brainPath } = await api('/api/memory');
322
+ if (!facts?.length) {
323
+ page.innerHTML = `<div class="card"><h2>🧠 ${t('memory.title')}</h2><p class="hint">${t('memory.empty')}</p></div>`;
324
+ return;
325
+ }
326
+ const rows = facts
327
+ .map((f) => `<tr>
328
+ <td>${escapeHtml(f.text)}</td>
329
+ <td><span class="pill">${escapeHtml(f.noteType)}</span></td>
330
+ <td>${escapeHtml(f.trust)}${f.tier === 'protected' ? ' 🔒' : ''}</td>
331
+ <td>${f.importance}</td>
332
+ </tr>`)
333
+ .join('');
334
+ page.innerHTML = `<div class="card"><h2>🧠 ${t('memory.title')} <span class="pill">${facts.length}</span></h2>
335
+ ${brainPath ? `<p class="hint">${t('memory.brain')}: ${escapeHtml(brainPath)}</p>` : ''}
336
+ <table class="table"><thead><tr><th>fact</th><th>type</th><th>trust</th><th>${t('memory.importance')}</th></tr></thead>
337
+ <tbody>${rows}</tbody></table></div>`;
338
+ }
339
+
340
+ async function renderPersona(page) {
341
+ const data = await api('/api/persona');
342
+ if (!data.hasProfile) {
343
+ page.innerHTML = `<div class="card"><h2>🪪 ${t('persona.title')}</h2>
344
+ <p class="hint">${t('persona.empty')}</p>
345
+ <p style="margin-top:12px;"><code>${escapeHtml(data.cliCommand)}</code></p>
346
+ <p class="hint" style="margin-top:8px;">หรือพิมพ์ <code>/persona</code> ใน REPL</p></div>`;
347
+ return;
348
+ }
349
+ const rows = data.rows
350
+ .filter((r) => r.value)
351
+ .map((r) => `<tr><td>${escapeHtml(r.label)}</td><td>${escapeHtml(r.display)}</td></tr>`)
352
+ .join('');
353
+ page.innerHTML = `<div class="card"><h2>🪪 ${t('persona.title')}</h2>
354
+ ${data.profilePath ? `<p class="hint">${t('persona.profile')}: ${escapeHtml(data.profilePath)}</p>` : ''}
355
+ <p class="hint">${t('persona.edit')}: <code>${escapeHtml(data.cliCommand)}</code> · <code>/persona</code></p>
356
+ <h3 style="margin:16px 0 8px;">${t('persona.rows')}</h3>
357
+ <table class="table"><thead><tr><th>หัวข้อ</th><th>ค่า</th></tr></thead><tbody>${rows}</tbody></table></div>`;
358
+ }
359
+
360
+ async function renderUsage(page) {
361
+ const { totals, daily } = await api('/api/usage');
362
+ if (!totals || totals.turns === 0) {
363
+ page.innerHTML = `<div class="card"><h2>💰 ${t('usage.title')}</h2><p class="hint">${t('usage.empty')}</p></div>`;
364
+ return;
365
+ }
366
+ const fmt = (n) => (n >= 1000 ? `${(n / 1000).toFixed(1)}k` : `${n}`);
367
+ const rows = daily
368
+ .map((d) => `<tr><td>${escapeHtml(d.label)}</td><td>${d.turns}</td><td>${fmt(d.totalTokens)}</td><td>$${d.costUsd.toFixed(4)}</td></tr>`)
369
+ .reverse()
370
+ .join('');
371
+ page.innerHTML = `<div class="card"><h2>💰 ${t('usage.title')}</h2>
372
+ <dl class="kv">
373
+ <dt>${t('usage.turns')}</dt><dd><span class="pill">${totals.turns}</span></dd>
374
+ <dt>${t('usage.tokens')}</dt><dd>${fmt(totals.totalTokens)}</dd>
375
+ <dt>${t('usage.cost')}</dt><dd><strong>$${totals.costUsd.toFixed(4)}</strong></dd>
376
+ </dl></div>
377
+ <div class="card"><h3>${t('usage.daily')}</h3>
378
+ <table class="table"><thead><tr><th>date</th><th>${t('usage.turns')}</th><th>${t('usage.tokens')}</th><th>${t('usage.cost')}</th></tr></thead>
379
+ <tbody>${rows}</tbody></table></div>`;
380
+ }
381
+
382
+ async function renderSelfimprove(page) {
383
+ const data = await api('/api/self-improve');
384
+ const head = `<div class="card"><h2>📈 ${t('selfimprove.title')}</h2>
385
+ <dl class="kv">
386
+ <dt>status</dt><dd><span class="pill" style="background:${data.enabled ? '#1e3a2f' : '#3a1e1e'};color:${data.enabled ? '#7ee0a8' : '#e0a8a8'};">${data.enabled ? t('selfimprove.enabled') : t('selfimprove.disabled')}</span></dd>
387
+ <dt>${t('selfimprove.threshold')}</dt><dd>${data.threshold} ${t('selfimprove.repeats')}</dd>
388
+ </dl></div>`;
389
+ if (!data.families?.length) {
390
+ page.innerHTML = `${head}<div class="card"><p class="hint">${t('selfimprove.empty')}</p></div>`;
391
+ return;
392
+ }
393
+ const rows = data.families
394
+ .map((f) => {
395
+ const pct = Math.min(100, Math.round((f.count / data.threshold) * 100));
396
+ const bar = `<div style="background:#1a2236;border-radius:6px;overflow:hidden;height:8px;width:120px;"><div style="background:${f.skillCreated ? '#7ee0a8' : '#38bdf8'};height:8px;width:${pct}%;"></div></div>`;
397
+ return `<tr>
398
+ <td>${escapeHtml(f.sample)}</td>
399
+ <td>${f.count}/${data.threshold} ${bar}</td>
400
+ <td>${f.skillCreated ? `✨ ${escapeHtml(f.skillName ?? '')}` : '—'}</td>
401
+ </tr>`;
402
+ })
403
+ .join('');
404
+ page.innerHTML = `${head}<div class="card">
405
+ <table class="table"><thead><tr><th>task</th><th>${t('selfimprove.repeats')}</th><th>${t('selfimprove.skill')}</th></tr></thead>
406
+ <tbody>${rows}</tbody></table></div>`;
407
+ }
408
+
409
+ async function renderInstall(page) {
410
+ const data = await api('/api/install');
411
+ const blocks = data.methods
412
+ .map((m) => {
413
+ const badge = m.ready
414
+ ? `<span class="pill" style="background:#1e3a2f;color:#7ee0a8;">${t('install.ready')}</span>`
415
+ : `<span class="pill" style="background:#3a341e;color:#e0d08a;">${t('install.soon')}</span>`;
416
+ const cmds = m.commands
417
+ .map((c) => `<div style="margin:6px 0;"><div class="hint" style="margin:0 0 2px;">${escapeHtml(c.os)}</div>
418
+ <div style="display:flex;gap:8px;align-items:center;"><code style="flex:1;background:#0a1020;border:1px solid #2a3550;border-radius:8px;padding:8px 10px;display:block;overflow-x:auto;">${escapeHtml(c.cmd)}</code>
419
+ <button class="copy-btn" data-cmd="${escapeHtml(c.cmd)}" style="padding:6px 10px;border-radius:8px;border:0;background:#243049;color:#cfe0ff;cursor:pointer;">copy</button></div></div>`)
420
+ .join('');
421
+ return `<div class="card" style="margin-bottom:12px;">
422
+ <h3 style="margin:0 0 8px;">${escapeHtml(m.label)} ${m.recommended ? '<span class="pill">recommended</span>' : ''} ${badge}</h3>
423
+ ${cmds}
424
+ ${m.note ? `<p class="hint" style="margin:8px 0 0;">${escapeHtml(m.note)}</p>` : ''}
425
+ </div>`;
426
+ })
427
+ .join('');
428
+ page.innerHTML = `<h2 style="margin:0 0 12px;">${t('install.title')}</h2>${blocks}`;
429
+ page.querySelectorAll('.copy-btn').forEach((el) => {
430
+ el.onclick = async () => {
431
+ try {
432
+ await navigator.clipboard.writeText(el.getAttribute('data-cmd') ?? '');
433
+ const prev = el.textContent;
434
+ el.textContent = '✓';
435
+ setTimeout(() => (el.textContent = prev), 1200);
436
+ } catch {
437
+ /* clipboard may be blocked on http */
438
+ }
439
+ };
440
+ });
441
+ }
442
+
443
+ function appendConsole(out, html) {
444
+ const atBottom = out.scrollHeight - out.scrollTop - out.clientHeight < 40;
445
+ out.insertAdjacentHTML('beforeend', html);
446
+ if (atBottom) out.scrollTop = out.scrollHeight;
447
+ }
448
+
449
+ async function streamAgentRun(prompt, out, opts) {
450
+ const ctrl = new AbortController();
451
+ termAbort = ctrl;
452
+ appendConsole(out, `<div class="term-line term-user">❯ ${escapeHtml(prompt)}</div>`);
453
+ const answerEl = document.createElement('div');
454
+ answerEl.className = 'term-line term-assistant';
455
+ out.appendChild(answerEl);
456
+ let answer = '';
457
+ try {
458
+ const res = await fetch('/api/terminal/run', {
459
+ method: 'POST',
460
+ headers: { 'Content-Type': 'application/json' },
461
+ body: JSON.stringify({ prompt, sessionId: termSessionId, autoApprove: opts.autoApprove }),
462
+ signal: ctrl.signal,
463
+ });
464
+ const reader = res.body.getReader();
465
+ const decoder = new TextDecoder();
466
+ let buffer = '';
467
+ for (;;) {
468
+ const { value, done } = await reader.read();
469
+ if (done) break;
470
+ buffer += decoder.decode(value, { stream: true });
471
+ const parts = buffer.split('\n\n');
472
+ buffer = parts.pop() ?? '';
473
+ for (const part of parts) {
474
+ const line = part.split('\n').find((l) => l.startsWith('data: '));
475
+ if (!line) continue;
476
+ let ev;
477
+ try {
478
+ ev = JSON.parse(line.slice(6));
479
+ } catch {
480
+ continue;
481
+ }
482
+ if (ev.type === 'text') {
483
+ answer += ev.text;
484
+ answerEl.textContent = answer;
485
+ if (out.scrollHeight - out.scrollTop - out.clientHeight < 80) out.scrollTop = out.scrollHeight;
486
+ } else if (ev.type === 'status') {
487
+ // lightweight status, skip noisy
488
+ } else if (ev.type === 'tool-call') {
489
+ appendConsole(out, `<div class="term-line term-tool">› ${escapeHtml(ev.title || ev.tool || '')}</div>`);
490
+ if (Array.isArray(ev.diff)) {
491
+ for (const d of ev.diff) {
492
+ const cls = d.sign === '+' ? 'term-add' : d.sign === '-' ? 'term-del' : 'term-ctx';
493
+ appendConsole(out, `<div class="term-line ${cls}"> ${d.sign === ' ' ? ' ' : escapeHtml(d.sign)} ${escapeHtml(d.text)}</div>`);
494
+ }
495
+ }
496
+ } else if (ev.type === 'memory') {
497
+ appendConsole(out, `<div class="term-line term-memory">🧠 ${escapeHtml(ev.fact)}</div>`);
498
+ } else if (ev.type === 'skill') {
499
+ appendConsole(out, `<div class="term-line term-skill">✨ Self-improvement: สร้าง skill \`${escapeHtml(ev.name)}\` จากงานที่ทำซ้ำ ${ev.count ?? ''} ครั้ง</div>`);
500
+ } else if (ev.type === 'error') {
501
+ appendConsole(out, `<div class="term-line term-error">⚠ ${escapeHtml(ev.message)}</div>`);
502
+ }
503
+ }
504
+ }
505
+ } catch (e) {
506
+ if (e.name !== 'AbortError') appendConsole(out, `<div class="term-line term-error">⚠ ${escapeHtml(e.message)}</div>`);
507
+ } finally {
508
+ termAbort = null;
509
+ }
510
+ }
511
+
512
+ async function renderTerminal(page) {
513
+ const tab = localStorage.getItem('sanook-term-tab') || 'agent';
514
+ page.innerHTML = `<div class="card">
515
+ <h2>${t('terminal.title')}</h2>
516
+ <p class="hint">${t('terminal.hint')}</p>
517
+ <div class="term-tabs">
518
+ <button class="term-tab${tab === 'agent' ? ' active' : ''}" data-tab="agent">${t('terminal.agent')}</button>
519
+ <button class="term-tab${tab === 'shell' ? ' active' : ''}" data-tab="shell">${t('terminal.shell')}</button>
520
+ </div>
521
+ <div id="term-body"></div>
522
+ </div>`;
523
+ page.querySelectorAll('.term-tab').forEach((el) => {
524
+ el.onclick = () => {
525
+ localStorage.setItem('sanook-term-tab', el.getAttribute('data-tab'));
526
+ renderTerminal(page);
527
+ };
528
+ });
529
+ const body = page.querySelector('#term-body');
530
+ if (tab === 'agent') renderAgentConsole(body);
531
+ else renderRawShell(body);
532
+ }
533
+
534
+ function renderAgentConsole(body) {
535
+ body.innerHTML = `
536
+ <div id="term-out" class="term-out"></div>
537
+ <div class="term-input-row">
538
+ <textarea id="term-input" rows="1" placeholder="${t('terminal.placeholder')}"></textarea>
539
+ <button id="term-run" class="term-run">${t('terminal.run')}</button>
540
+ </div>
541
+ <label class="term-approve"><input type="checkbox" id="term-auto" checked /> auto-approve tools</label>`;
542
+ const out = body.querySelector('#term-out');
543
+ const input = body.querySelector('#term-input');
544
+ const runBtn = body.querySelector('#term-run');
545
+ const auto = body.querySelector('#term-auto');
546
+ const submit = async () => {
547
+ const prompt = input.value.trim();
548
+ if (!prompt || termAbort) return;
549
+ input.value = '';
550
+ runBtn.textContent = t('terminal.stop');
551
+ await streamAgentRun(prompt, out, { autoApprove: auto.checked });
552
+ runBtn.textContent = t('terminal.run');
553
+ };
554
+ runBtn.onclick = () => {
555
+ if (termAbort) {
556
+ termAbort.abort();
557
+ return;
558
+ }
559
+ void submit();
560
+ };
561
+ input.onkeydown = (e) => {
562
+ if (e.key === 'Enter' && !e.shiftKey) {
563
+ e.preventDefault();
564
+ void submit();
565
+ }
566
+ };
567
+ input.focus();
568
+ }
569
+
570
+ async function renderRawShell(body) {
571
+ body.innerHTML = `<p class="hint">Loading…</p>`;
572
+ const status = await api('/api/terminal/shell-status');
573
+ if (!status.available) {
574
+ body.innerHTML = `<div class="term-out term-disabled"><p>${t('terminal.shellOff')}</p><p class="hint">${escapeHtml(status.reason)}</p></div>`;
575
+ return;
576
+ }
577
+ body.innerHTML = `<div id="xterm" class="term-xterm"></div>`;
578
+ try {
579
+ await loadXterm();
580
+ } catch {
581
+ body.innerHTML = `<div class="term-out term-disabled"><p>${t('terminal.shellOff')}</p><p class="hint">xterm.js failed to load (offline?)</p></div>`;
582
+ return;
583
+ }
584
+ const term = new window.Terminal({ cursorBlink: true, fontSize: 13, theme: { background: '#0a1020' } });
585
+ term.open(body.querySelector('#xterm'));
586
+ const proto = location.protocol === 'https:' ? 'wss' : 'ws';
587
+ const ws = new WebSocket(`${proto}://${location.host}/api/terminal/shell`);
588
+ ws.onmessage = (ev) => {
589
+ try {
590
+ const msg = JSON.parse(ev.data);
591
+ if (msg.type === 'data') term.write(msg.data);
592
+ } catch {
593
+ /* ignore */
594
+ }
595
+ };
596
+ term.onData((d) => ws.readyState === 1 && ws.send(JSON.stringify({ type: 'data', data: d })));
597
+ term.onResize(({ cols, rows }) => ws.readyState === 1 && ws.send(JSON.stringify({ type: 'resize', cols, rows })));
598
+ ws.onclose = () => term.write('\r\n\x1b[31m[shell closed]\x1b[0m\r\n');
599
+ }
600
+
601
+ function loadXterm() {
602
+ if (window.Terminal) return Promise.resolve();
603
+ return new Promise((resolve, reject) => {
604
+ const css = document.createElement('link');
605
+ css.rel = 'stylesheet';
606
+ css.href = 'https://cdn.jsdelivr.net/npm/xterm@5.3.0/css/xterm.min.css';
607
+ document.head.appendChild(css);
608
+ const s = document.createElement('script');
609
+ s.src = 'https://cdn.jsdelivr.net/npm/xterm@5.3.0/lib/xterm.min.js';
610
+ s.onload = () => resolve();
611
+ s.onerror = () => reject(new Error('xterm load failed'));
612
+ document.head.appendChild(s);
613
+ });
614
+ }
615
+
242
616
  async function renderRoute() {
243
617
  const hash = location.hash.replace(/^#/, '') || '/';
244
618
  const route = routes.find((r) => r.path === `#${hash}`) ?? routes[0];
@@ -251,9 +625,15 @@ async function renderRoute() {
251
625
  try {
252
626
  const map = {
253
627
  home: renderHome,
628
+ terminal: renderTerminal,
254
629
  chat: renderChat,
255
630
  models: renderModels,
256
631
  sessions: renderSessions,
632
+ skills: renderSkills,
633
+ memory: renderMemory,
634
+ persona: renderPersona,
635
+ usage: renderUsage,
636
+ selfimprove: renderSelfimprove,
257
637
  files: renderFiles,
258
638
  logs: renderLogs,
259
639
  cron: renderCron,
@@ -261,6 +641,7 @@ async function renderRoute() {
261
641
  config: renderConfig,
262
642
  mcp: renderMcp,
263
643
  brain: renderBrain,
644
+ install: renderInstall,
264
645
  };
265
646
  await (map[route.id] ?? renderHome)(page);
266
647
  } catch (e) {
@@ -83,3 +83,39 @@ nav { display: flex; flex-direction: column; gap: 0.35rem; flex: 1; }
83
83
  .shell { grid-template-columns: 1fr; }
84
84
  .sidebar { border-right: none; border-bottom: 1px solid var(--border); }
85
85
  }
86
+
87
+ /* ---- Web terminal ---- */
88
+ .term-tabs { display: flex; gap: 0.5rem; margin-bottom: 0.75rem; }
89
+ .term-tab {
90
+ background: var(--panel-2); color: var(--muted); border: 1px solid var(--border);
91
+ border-radius: 10px; padding: 0.4rem 0.9rem; cursor: pointer; font-size: 0.9rem;
92
+ }
93
+ .term-tab.active { color: #041018; background: linear-gradient(135deg, var(--accent), var(--accent-2)); border-color: transparent; font-weight: 600; }
94
+ .term-out {
95
+ background: #0a1020; border: 1px solid var(--border); border-radius: 12px;
96
+ padding: 12px; height: 52vh; min-height: 280px; overflow-y: auto;
97
+ font-family: ui-monospace, "SF Mono", Menlo, monospace; font-size: 13px; line-height: 1.5;
98
+ }
99
+ .term-line { white-space: pre-wrap; word-break: break-word; margin: 2px 0; }
100
+ .term-user { color: var(--accent); font-weight: 600; margin-top: 10px; }
101
+ .term-assistant { color: var(--text); }
102
+ .term-tool { color: #c7a3ff; }
103
+ .term-add { color: #7ee0a8; background: rgba(74, 222, 128, 0.08); }
104
+ .term-del { color: #ff9b9b; background: rgba(248, 113, 113, 0.08); }
105
+ .term-ctx { color: var(--muted); }
106
+ .term-memory { color: var(--good); }
107
+ .term-skill { color: #ffd479; }
108
+ .term-error { color: #ff8a8a; }
109
+ .term-disabled { display: grid; place-items: center; text-align: center; gap: 6px; }
110
+ .term-input-row { display: flex; gap: 8px; margin-top: 10px; align-items: flex-end; }
111
+ .term-input-row textarea {
112
+ flex: 1; resize: none; background: #0a1020; color: var(--text);
113
+ border: 1px solid var(--border); border-radius: 10px; padding: 10px 12px;
114
+ font-family: ui-monospace, monospace; font-size: 13px; max-height: 160px;
115
+ }
116
+ .term-run {
117
+ padding: 10px 18px; border-radius: 10px; border: 0; cursor: pointer; font-weight: 600;
118
+ background: linear-gradient(135deg, var(--accent), var(--accent-2)); color: #041018;
119
+ }
120
+ .term-approve { display: flex; align-items: center; gap: 6px; color: var(--muted); font-size: 0.82rem; margin-top: 8px; }
121
+ .term-xterm { height: 56vh; min-height: 300px; border: 1px solid var(--border); border-radius: 12px; overflow: hidden; padding: 6px; background: #0a1020; }