@danieltmn/tamnora 0.11.2 → 0.11.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,13 @@ Todos los cambios relevantes de este proyecto se documentan en este archivo.
5
5
  El formato sigue [Keep a Changelog](https://keepachangelog.com/es-ES/1.1.0/)
6
6
  y el proyecto usa [Versionado Semantico](https://semver.org/lang/es/).
7
7
 
8
+ ## [0.11.3] - 2026-10-09
9
+
10
+ ### Cambios
11
+
12
+ - feat(consola): sidebar, estructura y vista previa de codigo
13
+ - docs(website): iconos y logo en la landing (favicon + header/footer)
14
+
8
15
  ## [0.11.2] - 2026-10-09
9
16
 
10
17
  ### Arreglado
@@ -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();
@@ -663,7 +667,6 @@ function initLayout() {
663
667
  const widths = storage.getJson('tamnora-panel-widths', {}) || {};
664
668
  if (Number(widths.sidebar)) panels.sidebar.width = Math.min(panels.sidebar.max, Math.max(panels.sidebar.min, widths.sidebar));
665
669
  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
670
  // En movil el panel arranca cerrado (drawer); en desktop, la preferencia guardada.
668
671
  applyPanelWidths();
669
672
  attachResizer(panels.sidebar);
@@ -1573,10 +1576,6 @@ function basename(p) {
1573
1576
  return text.split(/[\\/]/).pop() || text;
1574
1577
  }
1575
1578
 
1576
- function persistCollapsedGroups() {
1577
- storage.setJson('tamnora-collapsed-groups', [...state.collapsedGroups]);
1578
- }
1579
-
1580
1579
  // Pestañas cerradas desde el último reporte: para que el server las olvide ya,
1581
1580
  // sin esperar el TTL de presencia.
1582
1581
  const presenceClosed = new Set();
@@ -1611,9 +1610,10 @@ function renderSessions(sessions) {
1611
1610
  for (const [key, items] of groups) {
1612
1611
  const groupLi = el('li', 'session-group');
1613
1612
  const head = el('div', 'group-head');
1614
- const collapsed = state.collapsedGroups.has(key);
1613
+ const open = state.expandedGroups.has(key);
1614
+ head.classList.toggle('open', open);
1615
1615
  const twisty = el('span', 'tw');
1616
- twisty.appendChild(icon(collapsed ? 'chevron-right' : 'chevron-down', 14));
1616
+ twisty.appendChild(icon(open ? 'chevron-down' : 'chevron-right', 14));
1617
1617
  head.append(twisty, icon('folder', 15, 'fi'), el('span', 'gname', key ? basename(key) : 'sin carpeta'), el('span', 'gcount', String(items.length)));
1618
1618
  head.title = key || 'sesiones sin carpeta';
1619
1619
  const delGroup = el('button', 'group-del');
@@ -1627,19 +1627,23 @@ function renderSessions(sessions) {
1627
1627
  });
1628
1628
  head.appendChild(delGroup);
1629
1629
  const list = el('ul', 'group-sessions');
1630
- list.hidden = collapsed;
1630
+ list.hidden = !open;
1631
1631
  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;
1632
+ const nowOpen = !state.expandedGroups.has(key);
1633
+ if (nowOpen) state.expandedGroups.add(key);
1634
+ else state.expandedGroups.delete(key);
1635
+ head.classList.toggle('open', nowOpen);
1636
+ list.hidden = !nowOpen;
1637
1637
  clear(twisty);
1638
- twisty.appendChild(icon(nowCollapsed ? 'chevron-right' : 'chevron-down', 14));
1638
+ twisty.appendChild(icon(nowOpen ? 'chevron-down' : 'chevron-right', 14));
1639
1639
  });
1640
1640
  for (const session of items) {
1641
1641
  const li = el('li', `session-item${session.id === state.sessionId ? ' active' : ''}`);
1642
- li.appendChild(el('span', 'session-title', session.title || '(sin título)'));
1642
+ const main = el('div', 'session-main');
1643
+ main.appendChild(el('span', 'session-title', session.title || '(sin título)'));
1644
+ const meta = [session.provider, session.model].filter(Boolean).join(' / ');
1645
+ if (meta) main.appendChild(el('span', 'session-sub', meta));
1646
+ li.appendChild(main);
1643
1647
  // Punto de actividad: el server lo dice en el listado, y el reconciliador
1644
1648
  // lo mantiene al dia si el run arranca despues de este render. El estado
1645
1649
  // "esperandote a vos" tiene su propio color: un run trabajando en otra
@@ -4931,7 +4935,7 @@ function refreshTwisty(li, open) {
4931
4935
  twisty.appendChild(icon(open ? 'chevron-down' : 'chevron-right', 14));
4932
4936
  }
4933
4937
 
4934
- const filePreviewState = { current: null, kind: null, loaded: false, open: false, content: '' };
4938
+ const filePreviewState = { current: null, kind: null, lang: null, loaded: false, open: false, content: '' };
4935
4939
 
4936
4940
  const filePreviewTools = setupPreviewTools({
4937
4941
  container: $('#file-modal'),
@@ -4947,6 +4951,36 @@ function filePreviewKind(name) {
4947
4951
  return null;
4948
4952
  }
4949
4953
 
4954
+ const CODE_LANGS = {
4955
+ js: 'js', mjs: 'js', cjs: 'js', jsx: 'js',
4956
+ ts: 'ts', tsx: 'ts',
4957
+ json: 'json',
4958
+ css: 'css',
4959
+ html: 'html', htm: 'html',
4960
+ xml: 'xml', svg: 'xml',
4961
+ yml: 'yaml', yaml: 'yaml',
4962
+ sh: 'bash', bash: 'bash',
4963
+ py: 'python',
4964
+ };
4965
+
4966
+ function filePreviewLang(name) {
4967
+ const ext = String(name ?? '').split('.').pop().toLowerCase();
4968
+ return CODE_LANGS[ext] || null;
4969
+ }
4970
+
4971
+ function renderCodeView(content, lang) {
4972
+ const gutter = $('#file-gutter');
4973
+ const node = $('#file-content');
4974
+ if (!lang) {
4975
+ node.textContent = content;
4976
+ if (gutter) gutter.textContent = '';
4977
+ return;
4978
+ }
4979
+ const lines = content.replace(/\r\n?/g, '\n').split('\n');
4980
+ if (gutter) gutter.textContent = lines.map((_, i) => i + 1).join('\n');
4981
+ node.innerHTML = highlight(content, lang);
4982
+ }
4983
+
4950
4984
  async function openFilePreview(relPath, name) {
4951
4985
  const params = new URLSearchParams({ root: state.cwd, path: relPath });
4952
4986
  const data = await api(`/api/files/content?${params}`).catch((error) => {
@@ -4954,31 +4988,36 @@ async function openFilePreview(relPath, name) {
4954
4988
  return null;
4955
4989
  });
4956
4990
  if (!data) return;
4991
+ const code = $('#file-code');
4992
+ const lang = filePreviewLang(name);
4993
+ filePreviewState.current = { relPath, name };
4994
+ filePreviewState.kind = filePreviewKind(name);
4995
+ filePreviewState.lang = lang;
4996
+ filePreviewState.loaded = false;
4997
+ filePreviewState.open = false;
4998
+ filePreviewState.content = data.content;
4999
+
4957
5000
  $('#file-title').textContent = name;
4958
- $('#file-meta').textContent = `${data.size} bytes`;
4959
- $('#file-content').textContent = data.content;
5001
+ $('#file-meta').textContent = lang ? `${data.size} bytes · ${lang}` : `${data.size} bytes`;
5002
+ renderCodeView(data.content, lang);
5003
+ code.classList.toggle('has-lang', Boolean(lang));
4960
5004
 
4961
5005
  const toggle = $('#file-preview-toggle');
4962
5006
  const device = $('#file-device');
4963
5007
  const maxButton = $('#file-max');
4964
5008
  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
5009
 
4971
5010
  toggle.hidden = !filePreviewState.kind;
4972
5011
  toggle.textContent = 'vista previa';
4973
5012
  device.hidden = true;
4974
5013
  device.value = 'auto';
4975
- maxButton.hidden = true;
5014
+ maxButton.hidden = false;
4976
5015
  $('#file-frame').src = 'about:blank';
4977
5016
  $('#file-stage').hidden = true;
4978
5017
  $('#file-render').hidden = true;
4979
5018
  $('#file-render').innerHTML = '';
4980
- $('#file-content').hidden = false;
4981
- modal.classList.remove('preview-maximized');
5019
+ code.hidden = false;
5020
+ filePreviewTools.setMax(false);
4982
5021
  filePreviewTools.applyDevice();
4983
5022
 
4984
5023
  modal.showModal();
@@ -4986,7 +5025,7 @@ async function openFilePreview(relPath, name) {
4986
5025
 
4987
5026
  $('#file-preview-toggle')?.addEventListener('click', async () => {
4988
5027
  const toggle = $('#file-preview-toggle');
4989
- const content = $('#file-content');
5028
+ const code = $('#file-code');
4990
5029
  const stage = $('#file-stage');
4991
5030
  const frame = $('#file-frame');
4992
5031
  const render = $('#file-render');
@@ -4998,22 +5037,22 @@ $('#file-preview-toggle')?.addEventListener('click', async () => {
4998
5037
  filePreviewState.open = !filePreviewState.open;
4999
5038
  const off = () => {
5000
5039
  toggle.textContent = 'vista previa';
5001
- content.hidden = false;
5040
+ code.hidden = false;
5002
5041
  stage.hidden = true;
5003
5042
  render.hidden = true;
5004
5043
  device.hidden = true;
5005
- maxButton.hidden = true;
5044
+ maxButton.hidden = false;
5006
5045
  $('#file-modal').classList.remove('preview-maximized');
5007
5046
  };
5008
5047
  if (!filePreviewState.open) return off();
5009
5048
 
5010
5049
  if (filePreviewState.kind === 'markdown') {
5011
5050
  render.innerHTML = renderMarkdown(filePreviewState.content);
5012
- content.hidden = true;
5051
+ code.hidden = true;
5013
5052
  stage.hidden = true;
5014
5053
  render.hidden = false;
5015
5054
  device.hidden = true;
5016
- maxButton.hidden = true;
5055
+ maxButton.hidden = false;
5017
5056
  toggle.textContent = 'ver código';
5018
5057
  return;
5019
5058
  }
@@ -5035,7 +5074,7 @@ $('#file-preview-toggle')?.addEventListener('click', async () => {
5035
5074
  filePreviewTools.applyDevice();
5036
5075
  }
5037
5076
  toggle.textContent = 'ver código';
5038
- content.hidden = true;
5077
+ code.hidden = true;
5039
5078
  render.hidden = true;
5040
5079
  stage.hidden = false;
5041
5080
  });
@@ -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.11.3</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>
@@ -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);
@@ -64,8 +64,25 @@ test('a lone pipe line without a delimiter row stays a paragraph', () => {
64
64
  assert.equal(html, '<p>foo | bar</p>');
65
65
  });
66
66
 
67
- test('highlight escapes code content', () => {
68
- assert.equal(highlight('a < b', 'bash'), 'a &lt; b');
67
+ test('highlight escapes code content', () => {
68
+ assert.equal(highlight('a < b', 'bash'), 'a &lt; b');
69
+ });
70
+
71
+ test('highlight resalta html: tags, atributos, strings y comentarios', () => {
72
+ const html = highlight('<a href="/x">hola</a><!-- nota -->', 'html');
73
+ assert.match(html, /tok-tag">a</);
74
+ assert.match(html, /tok-attr">href</);
75
+ assert.match(html, /tok-string">&quot;\/x&quot;</);
76
+ assert.match(html, /tok-comment">&lt;!-- nota --&gt;</);
77
+ assert.ok(!html.includes('<a '));
78
+ });
79
+
80
+ test('highlight resalta css: selectores, propiedades, colores y medidas', () => {
81
+ const css = highlight('.btn { color: #fff; width: 10px; }', 'css');
82
+ assert.match(css, /tok-selector">\.btn</);
83
+ assert.match(css, /tok-attr">color</);
84
+ assert.match(css, /tok-number">#fff</);
85
+ assert.match(css, /tok-number">10px</);
69
86
  });
70
87
 
71
88
  test('speechText saca bloques de codigo y tablas', () => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danieltmn/tamnora",
3
- "version": "0.11.2",
3
+ "version": "0.11.3",
4
4
  "description": "Harness local para IAs: agente CLI-first con TUI, consola web y evaluacion. Node puro, sin dependencias.",
5
5
  "type": "module",
6
6
  "packageManager": "pnpm@11.25.0",