@danieltmn/tamnora 0.11.2 → 0.12.0

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.
Files changed (50) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/README.md +7 -1
  3. package/harness/packages/cli/src/commands/repl.js +18 -5
  4. package/harness/packages/cli/src/commands/run.js +11 -2
  5. package/harness/packages/cli/src/commands/tui.js +13 -3
  6. package/harness/packages/cli/src/lib/runtime.js +2 -0
  7. package/harness/packages/cli/src/tui/app.js +22 -0
  8. package/harness/packages/core/src/agent/compact.js +38 -0
  9. package/harness/packages/core/src/agent/context.js +13 -8
  10. package/harness/packages/core/src/agent/runAgent.js +15 -2
  11. package/harness/packages/core/src/agent/scope.js +15 -0
  12. package/harness/packages/core/src/config/index.js +4 -0
  13. package/harness/packages/core/src/index.js +10 -1
  14. package/harness/packages/core/src/providers/cache.js +29 -0
  15. package/harness/packages/core/src/providers/ollama.js +3 -2
  16. package/harness/packages/core/src/providers/openaiCompatible.js +9 -2
  17. package/harness/packages/core/src/providers/openrouter.js +6 -1
  18. package/harness/packages/core/src/providers/presets.js +7 -4
  19. package/harness/packages/core/src/providers/stream.js +45 -4
  20. package/harness/packages/core/src/skills/index.js +21 -0
  21. package/harness/packages/core/src/tools/executors.js +21 -13
  22. package/harness/packages/core/src/tools/index.js +2 -2
  23. package/harness/packages/core/src/tools/registry.js +3 -2
  24. package/harness/packages/core/test/agent.test.js +38 -0
  25. package/harness/packages/core/test/compact.test.js +33 -0
  26. package/harness/packages/core/test/context.test.js +14 -0
  27. package/harness/packages/core/test/providers.test.js +66 -0
  28. package/harness/packages/core/test/skills.test.js +10 -1
  29. package/harness/packages/core/test/stream.test.js +20 -0
  30. package/harness/packages/core/test/tools.test.js +18 -0
  31. package/harness/packages/server/public/app.js +93 -38
  32. package/harness/packages/server/public/index.html +8 -2
  33. package/harness/packages/server/public/markdown.js +57 -3
  34. package/harness/packages/server/public/styles.css +117 -13
  35. package/harness/packages/server/src/lib/agentRun.js +7 -7
  36. package/harness/packages/server/src/lib/agentTools.js +18 -3
  37. package/harness/packages/server/src/lib/approvals.js +7 -0
  38. package/harness/packages/server/src/lib/channels/telegram.js +9 -1
  39. package/harness/packages/server/src/lib/compact.js +77 -0
  40. package/harness/packages/server/src/lib/prompt.js +4 -1
  41. package/harness/packages/server/src/lib/skills.js +12 -1
  42. package/harness/packages/server/src/lib/tasks.js +2 -1
  43. package/harness/packages/server/src/routes/chat.js +21 -2
  44. package/harness/packages/server/src/routes/providers.js +4 -1
  45. package/harness/packages/server/src/routes/sessions.js +4 -50
  46. package/harness/packages/server/src/server.js +8 -2
  47. package/harness/packages/server/test/markdown.test.js +19 -2
  48. package/harness/packages/server/test/scope.test.js +68 -0
  49. package/harness/packages/server/test/server.test.js +130 -49
  50. package/package.json +1 -1
@@ -1,6 +1,6 @@
1
1
  // Tamnora console — dependency-free client for the harness server (terminal theme).
2
2
 
3
- import { renderMarkdown, speechText } from './markdown.js';
3
+ import { renderMarkdown, speechText, highlight } from './markdown.js';
4
4
  import { streamSse, followRun } from './runStream.js';
5
5
  import { diffState, pollDelayMs, localState, summarizeArgs } from './reconcile.js';
6
6
 
@@ -21,8 +21,10 @@ const state = {
21
21
  panes: new Map(),
22
22
  tabOrder: [],
23
23
  activeTab: null,
24
- collapsedGroups: new Set(),
25
24
  queues: new Map(),
25
+ // Grupos de sesiones abiertos a mano. Arranca vacio y NO se persiste: el
26
+ // sidebar siempre empieza con todas las carpetas sin expandir.
27
+ expandedGroups: new Set(),
26
28
  // Sesiones con un run vivo segun el canal global de eventos. El sidebar lo
27
29
  // usa para el punto de actividad antes de abrir la sesion.
28
30
  remoteActive: new Set(),
@@ -173,6 +175,7 @@ const storage = {
173
175
  const moreTab = $('#more-tab');
174
176
  const moreMenu = $('#more-menu');
175
177
  const moreTabLabel = $('#more-tab-label');
178
+ const topbarSection = $('#topbar-section');
176
179
 
177
180
  const TAB_LABELS = { chat: 'chat', providers: 'proveedores', ajustes: 'ajustes', eval: 'eval', runs: 'runs' };
178
181
 
@@ -186,6 +189,7 @@ function activateTab(name) {
186
189
  .querySelectorAll('.tab[data-tab], #more-menu button[data-tab]')
187
190
  .forEach((t) => t.classList.toggle('active', t.dataset.tab === name));
188
191
  if (moreTabLabel) moreTabLabel.textContent = TAB_LABELS[name] || name;
192
+ if (topbarSection) topbarSection.textContent = TAB_LABELS[name] || name;
189
193
  moreTab.classList.toggle('active', name === 'eval' || name === 'runs');
190
194
  document.querySelectorAll('.view').forEach((v) => v.classList.toggle('active', v.id === `view-${name}`));
191
195
  hideMoreMenu();
@@ -427,6 +431,7 @@ const providerSelect = $('#provider');
427
431
  const modelSelect = $('#model');
428
432
  const modeSelect = $('#mode');
429
433
  const yesInput = $('#yes');
434
+ const strictInput = $('#strict-scope');
430
435
 
431
436
  async function loadModes() {
432
437
  const data = await api('/api/modes').catch(() => ({ modes: [] }));
@@ -441,6 +446,7 @@ async function loadModes() {
441
446
  const settings = await api('/api/settings').catch(() => null);
442
447
  const active = settings?.settings?.mode || 'agent';
443
448
  if ([...modeSelect.options].some((o) => o.value === active)) modeSelect.value = active;
449
+ if (strictInput) strictInput.checked = settings?.settings?.strictScope === true;
444
450
  defaultCwdCache = settings?.settings?.defaultCwd || '';
445
451
  if (!defaultCwdCache) {
446
452
  // Migración: la carpeta predeterminada vivía en localStorage; la subimos al
@@ -545,6 +551,9 @@ document.addEventListener('keydown', (event) => {
545
551
  });
546
552
  modelPill?.addEventListener('click', () => openSettingsModal());
547
553
  yesInput?.addEventListener('change', renderModePill);
554
+ strictInput?.addEventListener('change', () =>
555
+ api('/api/settings', { method: 'PUT', body: { strictScope: strictInput.checked } }).catch(() => null)
556
+ );
548
557
 
549
558
  function updateStatus(extra = {}) {
550
559
  renderModePill();
@@ -663,7 +672,6 @@ function initLayout() {
663
672
  const widths = storage.getJson('tamnora-panel-widths', {}) || {};
664
673
  if (Number(widths.sidebar)) panels.sidebar.width = Math.min(panels.sidebar.max, Math.max(panels.sidebar.min, widths.sidebar));
665
674
  if (Number(widths.explorer)) panels.explorer.width = Math.min(panels.explorer.max, Math.max(panels.explorer.min, widths.explorer));
666
- state.collapsedGroups = new Set(storage.getJson('tamnora-collapsed-groups', []) || []);
667
675
  // En movil el panel arranca cerrado (drawer); en desktop, la preferencia guardada.
668
676
  applyPanelWidths();
669
677
  attachResizer(panels.sidebar);
@@ -1053,6 +1061,7 @@ const ACTIVITY = {
1053
1061
  thinking: { cls: 'loader-thinking', label: 'pensando…' },
1054
1062
  working: { cls: 'loader-working', label: 'trabajando…' },
1055
1063
  reconnecting: { cls: 'loader-working', label: 'reconectando…' },
1064
+ stopping: { cls: 'loader-working', label: 'deteniendo…' },
1056
1065
  };
1057
1066
 
1058
1067
  const footerActivityEl = $('#sb-activity');
@@ -1573,10 +1582,6 @@ function basename(p) {
1573
1582
  return text.split(/[\\/]/).pop() || text;
1574
1583
  }
1575
1584
 
1576
- function persistCollapsedGroups() {
1577
- storage.setJson('tamnora-collapsed-groups', [...state.collapsedGroups]);
1578
- }
1579
-
1580
1585
  // Pestañas cerradas desde el último reporte: para que el server las olvide ya,
1581
1586
  // sin esperar el TTL de presencia.
1582
1587
  const presenceClosed = new Set();
@@ -1611,9 +1616,10 @@ function renderSessions(sessions) {
1611
1616
  for (const [key, items] of groups) {
1612
1617
  const groupLi = el('li', 'session-group');
1613
1618
  const head = el('div', 'group-head');
1614
- const collapsed = state.collapsedGroups.has(key);
1619
+ const open = state.expandedGroups.has(key);
1620
+ head.classList.toggle('open', open);
1615
1621
  const twisty = el('span', 'tw');
1616
- twisty.appendChild(icon(collapsed ? 'chevron-right' : 'chevron-down', 14));
1622
+ twisty.appendChild(icon(open ? 'chevron-down' : 'chevron-right', 14));
1617
1623
  head.append(twisty, icon('folder', 15, 'fi'), el('span', 'gname', key ? basename(key) : 'sin carpeta'), el('span', 'gcount', String(items.length)));
1618
1624
  head.title = key || 'sesiones sin carpeta';
1619
1625
  const delGroup = el('button', 'group-del');
@@ -1627,19 +1633,23 @@ function renderSessions(sessions) {
1627
1633
  });
1628
1634
  head.appendChild(delGroup);
1629
1635
  const list = el('ul', 'group-sessions');
1630
- list.hidden = collapsed;
1636
+ list.hidden = !open;
1631
1637
  head.addEventListener('click', () => {
1632
- const nowCollapsed = !state.collapsedGroups.has(key);
1633
- if (nowCollapsed) state.collapsedGroups.add(key);
1634
- else state.collapsedGroups.delete(key);
1635
- persistCollapsedGroups();
1636
- list.hidden = nowCollapsed;
1638
+ const nowOpen = !state.expandedGroups.has(key);
1639
+ if (nowOpen) state.expandedGroups.add(key);
1640
+ else state.expandedGroups.delete(key);
1641
+ head.classList.toggle('open', nowOpen);
1642
+ list.hidden = !nowOpen;
1637
1643
  clear(twisty);
1638
- twisty.appendChild(icon(nowCollapsed ? 'chevron-right' : 'chevron-down', 14));
1644
+ twisty.appendChild(icon(nowOpen ? 'chevron-down' : 'chevron-right', 14));
1639
1645
  });
1640
1646
  for (const session of items) {
1641
1647
  const li = el('li', `session-item${session.id === state.sessionId ? ' active' : ''}`);
1642
- li.appendChild(el('span', 'session-title', session.title || '(sin título)'));
1648
+ const main = el('div', 'session-main');
1649
+ main.appendChild(el('span', 'session-title', session.title || '(sin título)'));
1650
+ const meta = [session.provider, session.model].filter(Boolean).join(' / ');
1651
+ if (meta) main.appendChild(el('span', 'session-sub', meta));
1652
+ li.appendChild(main);
1643
1653
  // Punto de actividad: el server lo dice en el listado, y el reconciliador
1644
1654
  // lo mantiene al dia si el run arranca despues de este render. El estado
1645
1655
  // "esperandote a vos" tiene su propio color: un run trabajando en otra
@@ -2522,11 +2532,21 @@ async function sendMessage() {
2522
2532
  }
2523
2533
 
2524
2534
  if (stopBtn) {
2525
- stopBtn.addEventListener('click', () => {
2535
+ stopBtn.addEventListener('click', async () => {
2526
2536
  const id = state.sessionId;
2527
- if (id) api(`/api/sessions/${id}/stop`, { method: 'POST' }).catch(() => {});
2528
- const controller = controllers.get(id);
2529
- if (controller) controller.abort();
2537
+ if (!id || stopBtn.disabled) return;
2538
+ stopBtn.disabled = true;
2539
+ renderFooterActivity('stopping');
2540
+ // Abortamos el stream local (corta el seguimiento ya) y pedimos al server que
2541
+ // aborte el run: sin esto el run es durable y seguiria generando.
2542
+ controllers.get(id)?.abort();
2543
+ try {
2544
+ await api(`/api/sessions/${id}/stop`, { method: 'POST' });
2545
+ } catch (error) {
2546
+ toast(`No se pudo detener el run: ${error.message}`);
2547
+ } finally {
2548
+ stopBtn.disabled = false;
2549
+ }
2530
2550
  });
2531
2551
  }
2532
2552
 
@@ -4931,7 +4951,7 @@ function refreshTwisty(li, open) {
4931
4951
  twisty.appendChild(icon(open ? 'chevron-down' : 'chevron-right', 14));
4932
4952
  }
4933
4953
 
4934
- const filePreviewState = { current: null, kind: null, loaded: false, open: false, content: '' };
4954
+ const filePreviewState = { current: null, kind: null, lang: null, loaded: false, open: false, content: '' };
4935
4955
 
4936
4956
  const filePreviewTools = setupPreviewTools({
4937
4957
  container: $('#file-modal'),
@@ -4947,6 +4967,36 @@ function filePreviewKind(name) {
4947
4967
  return null;
4948
4968
  }
4949
4969
 
4970
+ const CODE_LANGS = {
4971
+ js: 'js', mjs: 'js', cjs: 'js', jsx: 'js',
4972
+ ts: 'ts', tsx: 'ts',
4973
+ json: 'json',
4974
+ css: 'css',
4975
+ html: 'html', htm: 'html',
4976
+ xml: 'xml', svg: 'xml',
4977
+ yml: 'yaml', yaml: 'yaml',
4978
+ sh: 'bash', bash: 'bash',
4979
+ py: 'python',
4980
+ };
4981
+
4982
+ function filePreviewLang(name) {
4983
+ const ext = String(name ?? '').split('.').pop().toLowerCase();
4984
+ return CODE_LANGS[ext] || null;
4985
+ }
4986
+
4987
+ function renderCodeView(content, lang) {
4988
+ const gutter = $('#file-gutter');
4989
+ const node = $('#file-content');
4990
+ if (!lang) {
4991
+ node.textContent = content;
4992
+ if (gutter) gutter.textContent = '';
4993
+ return;
4994
+ }
4995
+ const lines = content.replace(/\r\n?/g, '\n').split('\n');
4996
+ if (gutter) gutter.textContent = lines.map((_, i) => i + 1).join('\n');
4997
+ node.innerHTML = highlight(content, lang);
4998
+ }
4999
+
4950
5000
  async function openFilePreview(relPath, name) {
4951
5001
  const params = new URLSearchParams({ root: state.cwd, path: relPath });
4952
5002
  const data = await api(`/api/files/content?${params}`).catch((error) => {
@@ -4954,31 +5004,36 @@ async function openFilePreview(relPath, name) {
4954
5004
  return null;
4955
5005
  });
4956
5006
  if (!data) return;
5007
+ const code = $('#file-code');
5008
+ const lang = filePreviewLang(name);
5009
+ filePreviewState.current = { relPath, name };
5010
+ filePreviewState.kind = filePreviewKind(name);
5011
+ filePreviewState.lang = lang;
5012
+ filePreviewState.loaded = false;
5013
+ filePreviewState.open = false;
5014
+ filePreviewState.content = data.content;
5015
+
4957
5016
  $('#file-title').textContent = name;
4958
- $('#file-meta').textContent = `${data.size} bytes`;
4959
- $('#file-content').textContent = data.content;
5017
+ $('#file-meta').textContent = lang ? `${data.size} bytes · ${lang}` : `${data.size} bytes`;
5018
+ renderCodeView(data.content, lang);
5019
+ code.classList.toggle('has-lang', Boolean(lang));
4960
5020
 
4961
5021
  const toggle = $('#file-preview-toggle');
4962
5022
  const device = $('#file-device');
4963
5023
  const maxButton = $('#file-max');
4964
5024
  const modal = $('#file-modal');
4965
- filePreviewState.current = { relPath, name };
4966
- filePreviewState.kind = filePreviewKind(name);
4967
- filePreviewState.loaded = false;
4968
- filePreviewState.open = false;
4969
- filePreviewState.content = data.content;
4970
5025
 
4971
5026
  toggle.hidden = !filePreviewState.kind;
4972
5027
  toggle.textContent = 'vista previa';
4973
5028
  device.hidden = true;
4974
5029
  device.value = 'auto';
4975
- maxButton.hidden = true;
5030
+ maxButton.hidden = false;
4976
5031
  $('#file-frame').src = 'about:blank';
4977
5032
  $('#file-stage').hidden = true;
4978
5033
  $('#file-render').hidden = true;
4979
5034
  $('#file-render').innerHTML = '';
4980
- $('#file-content').hidden = false;
4981
- modal.classList.remove('preview-maximized');
5035
+ code.hidden = false;
5036
+ filePreviewTools.setMax(false);
4982
5037
  filePreviewTools.applyDevice();
4983
5038
 
4984
5039
  modal.showModal();
@@ -4986,7 +5041,7 @@ async function openFilePreview(relPath, name) {
4986
5041
 
4987
5042
  $('#file-preview-toggle')?.addEventListener('click', async () => {
4988
5043
  const toggle = $('#file-preview-toggle');
4989
- const content = $('#file-content');
5044
+ const code = $('#file-code');
4990
5045
  const stage = $('#file-stage');
4991
5046
  const frame = $('#file-frame');
4992
5047
  const render = $('#file-render');
@@ -4998,22 +5053,22 @@ $('#file-preview-toggle')?.addEventListener('click', async () => {
4998
5053
  filePreviewState.open = !filePreviewState.open;
4999
5054
  const off = () => {
5000
5055
  toggle.textContent = 'vista previa';
5001
- content.hidden = false;
5056
+ code.hidden = false;
5002
5057
  stage.hidden = true;
5003
5058
  render.hidden = true;
5004
5059
  device.hidden = true;
5005
- maxButton.hidden = true;
5060
+ maxButton.hidden = false;
5006
5061
  $('#file-modal').classList.remove('preview-maximized');
5007
5062
  };
5008
5063
  if (!filePreviewState.open) return off();
5009
5064
 
5010
5065
  if (filePreviewState.kind === 'markdown') {
5011
5066
  render.innerHTML = renderMarkdown(filePreviewState.content);
5012
- content.hidden = true;
5067
+ code.hidden = true;
5013
5068
  stage.hidden = true;
5014
5069
  render.hidden = false;
5015
5070
  device.hidden = true;
5016
- maxButton.hidden = true;
5071
+ maxButton.hidden = false;
5017
5072
  toggle.textContent = 'ver código';
5018
5073
  return;
5019
5074
  }
@@ -5035,7 +5090,7 @@ $('#file-preview-toggle')?.addEventListener('click', async () => {
5035
5090
  filePreviewTools.applyDevice();
5036
5091
  }
5037
5092
  toggle.textContent = 'ver código';
5038
- content.hidden = true;
5093
+ code.hidden = true;
5039
5094
  render.hidden = true;
5040
5095
  stage.hidden = false;
5041
5096
  });
@@ -37,7 +37,7 @@
37
37
  </head>
38
38
  <body>
39
39
  <header class="topbar">
40
- <div class="brand"><span class="logo"><img src="/icons/logo.png" alt="Tamnora" /></span> <span class="ver">v0.11.2</span></div>
40
+ <div class="brand"><span class="logo"><img src="/icons/logo.png" alt="Tamnora" /></span> <span id="topbar-section" class="topbar-section">chat</span> <span class="ver">v0.12.0</span></div>
41
41
  <nav class="tabs">
42
42
  <button class="tab active" data-tab="chat">chat</button>
43
43
  <button class="tab" data-tab="providers">proveedores</button>
@@ -350,7 +350,10 @@
350
350
  </button>
351
351
  </div>
352
352
  </div>
353
- <pre id="file-content" class="file-preview"></pre>
353
+ <div id="file-code" class="code-view">
354
+ <pre id="file-gutter" class="code-gutter" aria-hidden="true"></pre>
355
+ <pre id="file-content" class="file-preview"></pre>
356
+ </div>
354
357
  <div id="file-render" class="file-render text" hidden></div>
355
358
  <div id="file-stage" class="preview-stage" data-device="auto" hidden>
356
359
  <iframe id="file-frame" class="file-frame" title="vista previa del archivo"></iframe>
@@ -483,6 +486,9 @@
483
486
  <label class="check" title="aprobar automáticamente tools con permiso ask">
484
487
  <input id="yes" type="checkbox" /> Aprobar automáticamente las tools (auto)
485
488
  </label>
489
+ <label class="check" title="el agente hace exactamente lo pedido, sin mejoras ni cambios fuera del pedido">
490
+ <input id="strict-scope" type="checkbox" /> Hacer solo lo pedido (estricto)
491
+ </label>
486
492
  </div>
487
493
  </div>
488
494
  <div class="row">
@@ -25,6 +25,9 @@ function normalizeLang(lang) {
25
25
  if (value === 'javascript' || value === 'mjs' || value === 'cjs') return 'js';
26
26
  if (value === 'shell' || value === 'zsh' || value === 'console') return 'bash';
27
27
  if (value === 'yml' || value === 'yaml') return 'yaml';
28
+ if (value === 'htm') return 'html';
29
+ if (value === 'svg') return 'xml';
30
+ if (value === 'scss' || value === 'less') return 'css';
28
31
  return value;
29
32
  }
30
33
 
@@ -32,9 +35,8 @@ function wrap(cls, text) {
32
35
  return `<span class="${cls}">${escapeHtml(text)}</span>`;
33
36
  }
34
37
 
35
- /** Minimal tokenizer: comments, strings, numbers and keywords. Best effort. */
36
- export function highlight(code, lang) {
37
- const language = normalizeLang(lang);
38
+ /** Tokenizer generico: comentarios, strings, numeros y keywords. Best effort. */
39
+ function highlightCode(code, language) {
38
40
  const keywords = new Set((KEYWORDS[language] ?? '').split(/\s+/).filter(Boolean));
39
41
  const pattern =
40
42
  /(\/\/[^\n]*|#[^\n]*|\/\*[\s\S]*?\*\/)|("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|`(?:[^`\\]|\\.)*`)|(\b\d[\d._]*\b)|([A-Za-z_$][\w$]*)/g;
@@ -55,6 +57,58 @@ export function highlight(code, lang) {
55
57
  return out + escapeHtml(code.slice(last));
56
58
  }
57
59
 
60
+ /** Resaltado de HTML/XML: comentarios, tags, atributos y strings. */
61
+ function highlightMarkup(code) {
62
+ const pattern =
63
+ /(<!--[\s\S]*?-->)|("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*')|(<\/?)([A-Za-z][\w:.-]*)|([A-Za-z_:][-A-Za-z0-9_:.]*)(?=\s*=)|(\/?>)/g;
64
+ let out = '';
65
+ let last = 0;
66
+ let match;
67
+ while ((match = pattern.exec(code))) {
68
+ out += escapeHtml(code.slice(last, match.index));
69
+ const [full, comment, str, bracket, tag, attr, close] = match;
70
+ if (comment) out += wrap('tok-comment', comment);
71
+ else if (str) out += wrap('tok-string', str);
72
+ else if (bracket) out += wrap('tok-punct', bracket) + (tag ? wrap('tok-tag', tag) : '');
73
+ else if (attr) out += wrap('tok-attr', attr);
74
+ else if (close) out += wrap('tok-punct', close);
75
+ else out += escapeHtml(full);
76
+ last = match.index + full.length;
77
+ }
78
+ return out + escapeHtml(code.slice(last));
79
+ }
80
+
81
+ /** Resaltado de CSS: comentarios, selectores, propiedades, at-rules y valores. */
82
+ function highlightCss(code) {
83
+ const pattern =
84
+ /(\/\*[\s\S]*?\*\/)|("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*')|(@[A-Za-z-]+)|(#[0-9a-fA-F]{3,8}\b)|(\b-?\d[\d.]*(?:px|em|rem|%|vh|vw|vmin|vmax|s|ms|fr|deg|ch|pt|ex)?\b)|([.#][A-Za-z_][\w-]*)|([-\w]+)(?=\s*:)/g;
85
+ let out = '';
86
+ let last = 0;
87
+ let match;
88
+ while ((match = pattern.exec(code))) {
89
+ out += escapeHtml(code.slice(last, match.index));
90
+ const [full, comment, str, at, color, num, selector, prop] = match;
91
+ if (comment) out += wrap('tok-comment', full);
92
+ else if (str) out += wrap('tok-string', full);
93
+ else if (at) out += wrap('tok-tag', full);
94
+ else if (color) out += wrap('tok-number', full);
95
+ else if (num) out += wrap('tok-number', full);
96
+ else if (selector) out += wrap('tok-selector', full);
97
+ else if (prop) out += wrap('tok-attr', full);
98
+ else out += escapeHtml(full);
99
+ last = match.index + full.length;
100
+ }
101
+ return out + escapeHtml(code.slice(last));
102
+ }
103
+
104
+ /** Minimal highlighter: best effort, always HTML-escaped. */
105
+ export function highlight(code, lang) {
106
+ const language = normalizeLang(lang);
107
+ if (language === 'html' || language === 'xml') return highlightMarkup(code);
108
+ if (language === 'css') return highlightCss(code);
109
+ return highlightCode(code, language);
110
+ }
111
+
58
112
  function inline(text) {
59
113
  let out = escapeHtml(text);
60
114
  const codes = [];
@@ -54,6 +54,10 @@ html[data-theme='paper'] .tok-comment { color: #7a7669; }
54
54
  html[data-theme='paper'] .tok-string { color: #7a4a2c; }
55
55
  html[data-theme='paper'] .tok-number { color: #8a5a00; }
56
56
  html[data-theme='paper'] .tok-keyword { color: #a3341f; }
57
+ html[data-theme='paper'] .tok-tag { color: #a3341f; }
58
+ html[data-theme='paper'] .tok-attr { color: #4f6b46; }
59
+ html[data-theme='paper'] .tok-selector { color: #8a5a00; }
60
+ html[data-theme='paper'] .tok-punct { color: #7a7669; }
57
61
  html[data-theme='paper'] .btn.primary { color: #fbf6ec; }
58
62
 
59
63
  * { box-sizing: border-box; }
@@ -118,6 +122,7 @@ pre, code, kbd, samp { font-family: var(--mono); }
118
122
  }
119
123
  .brand .logo img { width: 100%; height: 100%; display: block; object-fit: cover; }
120
124
  .brand .ver { color: var(--muted); font-family: var(--mono); font-weight: 400; font-size: 10px; letter-spacing: 0.04em; }
125
+ .brand .topbar-section { display: none; }
121
126
  .tabs { grid-column: 2; justify-self: center; display: flex; gap: 2px; }
122
127
  .tab {
123
128
  background: transparent;
@@ -245,13 +250,16 @@ main { flex: 1; min-height: 0; min-width: 0; display: flex; }
245
250
  display: flex;
246
251
  align-items: center;
247
252
  gap: 7px;
248
- padding: 5px 7px;
249
- border-radius: 7px;
253
+ padding: 7px 9px;
254
+ border-radius: 8px;
250
255
  cursor: pointer;
251
- color: var(--muted);
256
+ color: var(--text);
257
+ border: 1px solid transparent;
258
+ transition: background 0.12s ease, border-color 0.12s ease;
252
259
  }
253
- .group-head:hover { background: var(--panel); color: var(--text); }
254
- .group-head .tw { width: 14px; height: 14px; flex: 0 0 auto; display: grid; place-items: center; }
260
+ .group-head:hover { background: var(--panel); border-color: var(--border); }
261
+ .group-head .tw { width: 14px; height: 14px; flex: 0 0 auto; display: grid; place-items: center; color: var(--muted); }
262
+ .group-head .tw svg { transition: transform 0.15s ease; }
255
263
  .group-head .fi { width: 15px; height: 15px; flex: 0 0 auto; color: var(--accent); }
256
264
  .group-head .gname {
257
265
  flex: 1;
@@ -259,27 +267,65 @@ main { flex: 1; min-height: 0; min-width: 0; display: flex; }
259
267
  overflow: hidden;
260
268
  text-overflow: ellipsis;
261
269
  white-space: nowrap;
262
- font: 600 11px var(--sans);
270
+ font: 600 11.5px var(--sans);
263
271
  letter-spacing: 0.02em;
264
272
  }
265
- .group-head .gcount { font: 500 10px var(--mono); opacity: 0.65; }
266
- .group-sessions { list-style: none; margin: 0; padding: 3px 0 0 15px; display: flex; flex-direction: column; gap: 2px; }
273
+ .group-head .gcount {
274
+ flex: 0 0 auto;
275
+ min-width: 18px;
276
+ padding: 1px 6px;
277
+ text-align: center;
278
+ border-radius: 999px;
279
+ background: var(--theirs);
280
+ border: 1px solid var(--border);
281
+ font: 600 10px var(--mono);
282
+ color: var(--muted);
283
+ }
284
+ .group-sessions {
285
+ list-style: none;
286
+ margin: 3px 0 5px 9px;
287
+ padding: 0 0 0 11px;
288
+ border-left: 1px solid var(--border);
289
+ display: flex;
290
+ flex-direction: column;
291
+ gap: 2px;
292
+ }
267
293
  .session-item {
294
+ position: relative;
268
295
  display: flex;
269
296
  align-items: center;
270
297
  gap: 6px;
271
- padding: 6px 9px;
272
- border-radius: 6px;
298
+ padding: 7px 9px;
299
+ border-radius: 7px;
273
300
  cursor: pointer;
274
301
  color: var(--muted);
275
302
  white-space: nowrap;
276
303
  overflow: hidden;
277
304
  border: 1px solid transparent;
305
+ transition: background 0.12s ease, color 0.12s ease;
278
306
  }
279
307
  .session-item:hover { background: var(--panel); color: var(--text); }
280
308
  .session-item.active { background: var(--theirs); color: var(--text); border-color: var(--border); }
309
+ .session-item.active::before {
310
+ content: '';
311
+ position: absolute;
312
+ left: 0;
313
+ top: 7px;
314
+ bottom: 7px;
315
+ width: 2px;
316
+ border-radius: 2px;
317
+ background: var(--accent);
318
+ }
281
319
  .session-item .sub { color: var(--muted); font-size: 11px; overflow: hidden; text-overflow: ellipsis; }
282
- .session-item .session-title { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; }
320
+ .session-main { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 1px; }
321
+ .session-item .session-title { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
322
+ .session-item .session-sub {
323
+ color: var(--muted);
324
+ font: 500 10.5px var(--mono);
325
+ overflow: hidden;
326
+ text-overflow: ellipsis;
327
+ opacity: 0.75;
328
+ }
283
329
  .session-live {
284
330
  flex: none;
285
331
  width: 6px;
@@ -1108,6 +1154,10 @@ pre.code code { background: none; border: 0; padding: 0; font-size: 0.92em; }
1108
1154
  .tok-string { color: #c2a878; }
1109
1155
  .tok-number { color: #e0a05c; }
1110
1156
  .tok-keyword { color: #e0794f; }
1157
+ .tok-tag { color: #e0794f; }
1158
+ .tok-attr { color: #8fae7f; }
1159
+ .tok-selector { color: #d9a441; }
1160
+ .tok-punct { color: #9c9789; }
1111
1161
 
1112
1162
  .composer-status {
1113
1163
  display: flex;
@@ -1343,10 +1393,14 @@ pre.code code { background: none; border: 0; padding: 0; font-size: 0.92em; }
1343
1393
  .explorer-filter input { width: 100%; }
1344
1394
 
1345
1395
  .tree { list-style: none; margin: 0; padding: 2px 8px 12px; overflow-y: auto; flex: 1; min-height: 0; }
1346
- .tree .tree { padding: 0 0 0 13px; overflow: visible; }
1396
+ /* El subárbol queda oculto salvo que su carpeta esté abierta (clase .open que
1397
+ mantiene el JS). Sin esto, expandir no tenía vuelta atrás. */
1398
+ .tree .tree { padding: 0 0 0 13px; overflow: visible; display: none; }
1399
+ .tree li.open > .tree { display: block; }
1347
1400
  .tree-row { display: flex; align-items: center; gap: 6px; padding: 3px 6px; border-radius: 7px; cursor: pointer; white-space: nowrap; }
1348
1401
  .tree-row:hover { background: var(--theirs); }
1349
1402
  .tree-row .tw { width: 14px; height: 14px; flex: 0 0 auto; display: grid; place-items: center; color: var(--muted); }
1403
+ .tree-row .tw svg { transition: transform 0.15s ease; }
1350
1404
  .tree-row .fi { width: 15px; height: 15px; flex: 0 0 auto; color: var(--muted); }
1351
1405
  .tree-row.dir .fi { color: var(--accent); }
1352
1406
  .tree-name { overflow: hidden; text-overflow: ellipsis; min-width: 0; }
@@ -1431,6 +1485,42 @@ pre.code code { background: none; border: 0; padding: 0; font-size: 0.92em; }
1431
1485
  white-space: pre;
1432
1486
  }
1433
1487
 
1488
+ /* Visor de codigo: gutter con numeros de linea + resaltado (estilo VSCode). */
1489
+ .code-view {
1490
+ display: flex;
1491
+ align-items: stretch;
1492
+ max-height: 62vh;
1493
+ overflow: auto;
1494
+ background: var(--theirs);
1495
+ border: 1px solid var(--border);
1496
+ border-radius: 10px;
1497
+ }
1498
+ .code-view .file-preview {
1499
+ flex: 1;
1500
+ min-width: 0;
1501
+ max-height: none;
1502
+ overflow: visible;
1503
+ border: 0;
1504
+ border-radius: 0;
1505
+ background: transparent;
1506
+ }
1507
+ .code-gutter {
1508
+ position: sticky;
1509
+ left: 0;
1510
+ z-index: 1;
1511
+ flex: 0 0 auto;
1512
+ margin: 0;
1513
+ padding: 12px 8px 12px 10px;
1514
+ text-align: right;
1515
+ color: var(--muted);
1516
+ background: var(--panel);
1517
+ border-right: 1px solid var(--border);
1518
+ font: 12px/1.55 var(--mono);
1519
+ white-space: pre;
1520
+ user-select: none;
1521
+ }
1522
+ .code-view:not(.has-lang) .code-gutter { display: none; }
1523
+
1434
1524
  .file-render {
1435
1525
  margin: 0;
1436
1526
  padding: 14px 16px;
@@ -1467,10 +1557,12 @@ dialog.file-modal.preview-maximized {
1467
1557
  .file-modal.preview-maximized .modal-card { height: 100vh; }
1468
1558
  .file-modal.preview-maximized .file-stage,
1469
1559
  .file-modal.preview-maximized .file-render,
1560
+ .file-modal.preview-maximized .code-view,
1470
1561
  .file-modal.preview-maximized .file-preview {
1471
1562
  flex: 1;
1472
1563
  height: auto;
1473
1564
  max-height: none;
1565
+ min-height: 0;
1474
1566
  }
1475
1567
 
1476
1568
  /* panel heads + collapsed handles ----------------------------------------- */
@@ -1541,6 +1633,18 @@ dialog.file-modal.preview-maximized {
1541
1633
  .brand .ver {
1542
1634
  display: none;
1543
1635
  }
1636
+ .brand .topbar-section {
1637
+ display: inline-block;
1638
+ padding: 2px 8px;
1639
+ border-radius: 999px;
1640
+ border: 1px solid var(--border);
1641
+ background: var(--accent-soft);
1642
+ color: var(--accent);
1643
+ font: 700 10px var(--sans);
1644
+ text-transform: uppercase;
1645
+ letter-spacing: 0.08em;
1646
+ white-space: nowrap;
1647
+ }
1544
1648
  .tabs {
1545
1649
  margin-left: auto;
1546
1650
  max-width: 100%;
@@ -1549,7 +1653,7 @@ dialog.file-modal.preview-maximized {
1549
1653
  display: none;
1550
1654
  }
1551
1655
  #more-tab-label {
1552
- display: inline;
1656
+ display: none;
1553
1657
  }
1554
1658
  .menu-pop {
1555
1659
  top: calc(100% + 2px);
@@ -2,7 +2,7 @@
2
2
  // subagentes). La creacion de tools y el onEvent de cada canal siguen siendo
3
3
  // propios (los canales de salida son distintos); lo combinable vive aca.
4
4
 
5
- import { summarizeToolCall, previewToolResult } from './core.js';
5
+ import { summarizeToolCall, previewToolResult, liveHistory } from './core.js';
6
6
 
7
7
  const FAILED_RE = /^Error|^Permiso denegado/i;
8
8
 
@@ -21,16 +21,15 @@ export function toolCallPayload(event) {
21
21
  * `approve` es el callback de aprobacion de las tools de administraación.
22
22
  */
23
23
  export function combineToolset({ local, admin = null, tasks = null, exposeAdmin = false, approve = null }) {
24
+ const adminAllowed = (name) => Boolean(admin?.handles(name)) && (exposeAdmin || admin.readOnly?.has(name));
24
25
  const definitions = [
25
26
  ...local.definitions,
26
- ...(exposeAdmin && admin ? admin.definitions : []),
27
+ ...(admin ? admin.definitions.filter((d) => adminAllowed(d.function.name)) : []),
27
28
  ...(tasks ? tasks.definitions : []),
28
29
  ];
29
30
  const execute = (name, args, context) => {
30
31
  if (tasks?.handles(name)) return tasks.execute(name, args);
31
- if (exposeAdmin && admin?.handles(name)) {
32
- return admin.execute(name, args, approve ? { approve } : undefined);
33
- }
32
+ if (adminAllowed(name)) return admin.execute(name, args, approve ? { approve } : undefined);
34
33
  return local.execute(name, args, context);
35
34
  };
36
35
  return { definitions, execute };
@@ -67,12 +66,13 @@ export async function composeAgentMessages({ ctx, cwd, mode, history = [], userM
67
66
  const basePrompt = await ctx.systemPromptFor(cwd);
68
67
  const goal = sessionRecord?.meta?.goal;
69
68
  const summary = sessionRecord?.meta?.summary;
69
+ // Historial vigente: sin el asesor y sin lo ya resumido (si hay cursor).
70
+ const live = liveHistory(history, sessionRecord?.meta?.summaryCursor ?? null);
70
71
  return [
71
72
  { role: 'system', content: mode.systemPrompt ? `${basePrompt}\n\n${mode.systemPrompt}` : basePrompt },
72
73
  ...(goal ? [{ role: 'system', content: `Objetivo de la sesión: ${goal}` }] : []),
73
74
  ...(summary ? [{ role: 'system', content: `Resumen de la conversación anterior:\n${summary}` }] : []),
74
- // El asesor no contamina el contexto del modelo: es una capa de comentario.
75
- ...history.filter((message) => message.name !== 'advisor'),
75
+ ...live,
76
76
  userMessage,
77
77
  ];
78
78
  }