@danieltmn/tamnora 0.7.0 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,32 @@ 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.7.1] - 2026-10-06
9
+
10
+ ### Agregado
11
+
12
+ - **Tablas markdown**: la consola renderiza tablas con `|` (encabezado, alineación
13
+ `:--`/`:-:`/`--:`, celdas con `inline` y escape de HTML y de `\|`).
14
+ - **Saltos de scroll**: botones flotantes para ir al inicio o al final de la
15
+ conversación, visibles solo cuando hay scroll.
16
+
17
+ ### Corregido
18
+
19
+ - **Vista previa maximizada**: cubre toda la pantalla. El `backdrop-filter` del
20
+ panel creaba un *containing block* que dejaba el `position: fixed` encerrado en
21
+ la barra; ahora el contenedor se mueve a `body` al maximizar y vuelve a su lugar
22
+ al restaurar (también al cerrar el explorador).
23
+ - **Arranque de la consola**: `scrollNavFrame` se declaraba después de
24
+ `initLayout()` y disparaba un `ReferenceError` (TDZ) al cargar.
25
+
26
+ ### Cambiado
27
+
28
+ - **Indicador de actividad en el footer**: la animación de "pensando/trabajando"
29
+ deja de quedar pegada en cada respuesta; ahora hay una sola en el footer
30
+ (`#sb-activity`) para la sesión activa.
31
+ - **Acciones del mensaje**: *copiar / regenerar / editar / borrar desde acá* se
32
+ agrupan en un menú de **tres puntos** (`⋮`) en vez de mostrarse siempre.
33
+
8
34
  ## [0.7.0] - 2026-10-05
9
35
 
10
36
  ### Seguridad
@@ -58,6 +58,7 @@ const ICON_PATHS = {
58
58
  file: ['M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z', 'M14 2v4a2 2 0 0 0 2 2h4'],
59
59
  'refresh-cw': ['M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8', 'M21 3v5h-5', 'M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16', 'M8 16H3v5'],
60
60
  trash: ['M3 6h18', 'M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6', 'M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2', 'M10 11v6', 'M14 11v6'],
61
+ 'dots-vertical': ['M12 5h.01', 'M12 12h.01', 'M12 19h.01'],
61
62
  };
62
63
 
63
64
  function icon(name, size = 16, className = '') {
@@ -523,11 +524,13 @@ function persistLayout() {
523
524
  } catch {
524
525
  // storage unavailable
525
526
  }
527
+ scheduleScrollNav();
526
528
  }
527
529
 
528
530
  function applyPanelWidths() {
529
531
  panels.sidebar.el.style.width = `${panels.sidebar.width}px`;
530
532
  panels.explorer.el.style.width = `${panels.explorer.width}px`;
533
+ scheduleScrollNav();
531
534
  }
532
535
 
533
536
  function attachResizer(panel) {
@@ -609,6 +612,10 @@ function initLayout() {
609
612
  mobileQuery.addEventListener('change', () => setSidebarOpen(isMobile() ? false : readSidebarPref()));
610
613
  }
611
614
 
615
+ // `scrollNavFrame` debe existir antes de `initLayout()`: `applyPanelWidths`
616
+ // llama a `scheduleScrollNav()` durante el arranque (evita el TDZ).
617
+ let scrollNavFrame = 0;
618
+
612
619
  initLayout();
613
620
 
614
621
  function formatK(n) {
@@ -733,12 +740,50 @@ const controllers = new Map();
733
740
  const sessionsEl = $('#sessions');
734
741
 
735
742
  let activePane = blankPane;
743
+ let restorePreviewMax = null;
736
744
  const paneOf = (message) => message?.bubble?.closest('.messages') ?? activePane;
737
745
 
738
746
  const scrollMessages = (pane = activePane) => {
739
747
  if (pane) pane.scrollTop = pane.scrollHeight;
740
748
  };
741
749
 
750
+ const scrollNav = $('#scroll-nav');
751
+ const scrollTopBtn = $('#scroll-top');
752
+ const scrollDownBtn = $('#scroll-down');
753
+
754
+ /** Muestra/oculta los saltos de scroll según el pane activo. */
755
+ function updateScrollNav() {
756
+ const pane = activePane;
757
+ if (!pane) return;
758
+ const max = pane.scrollHeight - pane.clientHeight;
759
+ const visible = max > 40;
760
+ scrollNav.hidden = !visible;
761
+ if (!visible) return;
762
+ scrollTopBtn.disabled = pane.scrollTop < 40;
763
+ scrollDownBtn.disabled = max - pane.scrollTop < 40;
764
+ }
765
+
766
+ function scheduleScrollNav() {
767
+ if (scrollNavFrame) return;
768
+ scrollNavFrame = requestAnimationFrame(() => {
769
+ scrollNavFrame = 0;
770
+ updateScrollNav();
771
+ });
772
+ }
773
+
774
+ scrollTopBtn.addEventListener('click', () => activePane?.scrollTo({ top: 0, behavior: 'smooth' }));
775
+ scrollDownBtn.addEventListener('click', () =>
776
+ activePane?.scrollTo({ top: activePane.scrollHeight, behavior: 'smooth' })
777
+ );
778
+ // `scroll` no burbujea: se escucha en captura desde el contenedor de los panes.
779
+ panesEl.addEventListener('scroll', scheduleScrollNav, true);
780
+ window.addEventListener('resize', scheduleScrollNav);
781
+ new MutationObserver(scheduleScrollNav).observe(panesEl, {
782
+ childList: true,
783
+ characterData: true,
784
+ subtree: true,
785
+ });
786
+
742
787
  function addMessage(role, text = '', pane = activePane) {
743
788
  pane.querySelector('.empty')?.remove();
744
789
  const wrap = el('div', `msg ${role}`);
@@ -755,12 +800,10 @@ function addMessage(role, text = '', pane = activePane) {
755
800
  const bubble = el('div', 'bubble');
756
801
  const textEl = el('div', 'text');
757
802
  const tools = el('div', 'tools');
758
- const activity = el('div', 'activity');
759
- activity.hidden = true;
760
- bubble.append(textEl, tools, activity);
803
+ bubble.append(textEl, tools);
761
804
  wrap.append(label, bubble);
762
805
  pane.appendChild(wrap);
763
- const message = { role, wrap, bubble, label, modeEl, textEl, tools, activity, reasoningEl: null, id: null };
806
+ const message = { role, wrap, bubble, label, modeEl, textEl, tools, reasoningEl: null, id: null };
764
807
  addMessageActions(message);
765
808
  if (text) setText(message, text);
766
809
  scrollMessages(pane);
@@ -775,18 +818,79 @@ function setMessageMode(message, modeId) {
775
818
  message.modeEl.hidden = false;
776
819
  }
777
820
 
821
+ let openMessageMenu = null;
822
+
823
+ /** El menu flota (position: fixed) para no quedar recortado por el scroll. */
824
+ function positionMessageMenu() {
825
+ if (!openMessageMenu) return;
826
+ const { menu, trigger } = openMessageMenu;
827
+ const rect = trigger.getBoundingClientRect();
828
+ const size = menu.getBoundingClientRect();
829
+ let top = rect.bottom + 4;
830
+ if (top + size.height > window.innerHeight - 8) top = Math.max(8, rect.top - size.height - 4);
831
+ let left = rect.left;
832
+ if (left + size.width > window.innerWidth - 8) left = Math.max(8, window.innerWidth - size.width - 8);
833
+ menu.style.top = `${top}px`;
834
+ menu.style.left = `${left}px`;
835
+ }
836
+
837
+ function closeMessageMenus() {
838
+ if (!openMessageMenu) return;
839
+ const current = openMessageMenu;
840
+ openMessageMenu = null;
841
+ current.menu.hidden = true;
842
+ current.trigger.setAttribute('aria-expanded', 'false');
843
+ current.actions?.appendChild(current.menu);
844
+ }
845
+
846
+ function toggleMessageMenu(message) {
847
+ const wasOpen = openMessageMenu === message;
848
+ closeMessageMenus();
849
+ if (wasOpen) return;
850
+ document.body.appendChild(message.menu);
851
+ message.menu.hidden = false;
852
+ message.trigger.setAttribute('aria-expanded', 'true');
853
+ openMessageMenu = message;
854
+ positionMessageMenu();
855
+ }
856
+
857
+ document.addEventListener('click', (event) => {
858
+ if (!openMessageMenu) return;
859
+ if (event.target.closest('.msg-menu') || event.target.closest('.msg-menu-btn')) return;
860
+ closeMessageMenus();
861
+ });
862
+ document.addEventListener('keydown', (event) => {
863
+ if (event.key === 'Escape') closeMessageMenus();
864
+ });
865
+ window.addEventListener('resize', positionMessageMenu);
866
+ document.addEventListener('scroll', positionMessageMenu, true);
867
+
778
868
  function addMessageActions(message) {
779
869
  const actions = el('div', 'msg-actions');
780
- actions.appendChild(button('copiar', () => copyMessage(message), 'msg-act'));
781
- if (message.role === 'assistant') {
782
- actions.appendChild(button('regenerar', () => regenerate(message), 'msg-act'));
783
- }
784
- if (message.role === 'user') {
785
- actions.appendChild(button('editar', () => editMessage(message), 'msg-act'));
786
- }
787
- actions.appendChild(button('borrar desde acá', () => deleteFrom(message), 'msg-act danger'));
870
+ const trigger = button('Opciones...', () => toggleMessageMenu(message), 'msg-menu-btn');
871
+ trigger.title = 'opciones';
872
+ trigger.setAttribute('aria-haspopup', 'menu');
873
+ trigger.setAttribute('aria-expanded', 'false');
874
+ trigger.setAttribute('aria-label', 'opciones del mensaje');
875
+
876
+ const menu = el('div', 'msg-menu');
877
+ menu.hidden = true;
878
+ const addItem = (label, run, cls = 'msg-act') => {
879
+ menu.appendChild(button(label, () => {
880
+ closeMessageMenus();
881
+ run();
882
+ }, cls));
883
+ };
884
+ addItem('copiar', () => copyMessage(message));
885
+ if (message.role === 'assistant') addItem('regenerar', () => regenerate(message));
886
+ if (message.role === 'user') addItem('editar', () => editMessage(message));
887
+ addItem('borrar desde acá', () => deleteFrom(message), 'msg-act danger');
888
+
889
+ actions.append(trigger, menu);
788
890
  message.bubble.appendChild(actions);
789
891
  message.actions = actions;
892
+ message.menu = menu;
893
+ message.trigger = trigger;
790
894
  }
791
895
 
792
896
  function messageId(message) {
@@ -857,17 +961,25 @@ const ACTIVITY = {
857
961
  waiting: { cls: 'loader-thinking', label: 'esperando aprobación…' },
858
962
  };
859
963
 
860
- function setActivity(message, kind) {
861
- if (!message.activity) return;
964
+ const footerActivityEl = $('#sb-activity');
965
+
966
+ /** El indicador de "pensando/trabajando" vive en el footer (una sola vez). */
967
+ function renderFooterActivity(kind) {
968
+ if (!footerActivityEl) return;
862
969
  if (!kind) {
863
- message.activity.hidden = true;
864
- message.activity.replaceChildren();
970
+ footerActivityEl.hidden = true;
971
+ footerActivityEl.replaceChildren();
865
972
  return;
866
973
  }
867
- const def = ACTIVITY[kind];
868
- message.activity.hidden = false;
869
- message.activity.replaceChildren(el('span', `loader ${def.cls}`), el('span', 'activity-label', def.label));
870
- scrollMessages(paneOf(message));
974
+ const def = ACTIVITY[kind] ?? ACTIVITY.thinking;
975
+ footerActivityEl.hidden = false;
976
+ footerActivityEl.replaceChildren(el('span', `loader ${def.cls}`), el('span', 'activity-label', def.label));
977
+ }
978
+
979
+ function setActivity(message, kind) {
980
+ // Solo el turno de la sesion activa refleja su actividad en el footer.
981
+ if (paneOf(message) !== activePane) return;
982
+ renderFooterActivity(kind);
871
983
  }
872
984
 
873
985
  function setText(message, text) {
@@ -1336,6 +1448,8 @@ function showPane(id) {
1336
1448
  for (const item of state.panes.values()) item.el.hidden = item.id !== id;
1337
1449
  blankPane.hidden = Boolean(record);
1338
1450
  activePane = record ? record.el : blankPane;
1451
+ renderFooterActivity(record?.working ? 'thinking' : null);
1452
+ scheduleScrollNav();
1339
1453
  }
1340
1454
 
1341
1455
  function renderTabs() {
@@ -1395,8 +1509,9 @@ function activateSession(id) {
1395
1509
 
1396
1510
  function auditStatus(record) {
1397
1511
  submitBtn.disabled = record.working;
1398
- $('#sb-rounds').textContent = record.working ? 'pensando…' : 'listo';
1512
+ $('#sb-rounds').textContent = record.working ? '' : 'listo';
1399
1513
  $('#sb-rounds').classList.toggle('running', record.working);
1514
+ renderFooterActivity(record.working ? 'thinking' : null);
1400
1515
  }
1401
1516
 
1402
1517
  function closeTab(id) {
@@ -1437,8 +1552,9 @@ function setWorking(id, working) {
1437
1552
  if (id === state.sessionId) {
1438
1553
  submitBtn.disabled = working;
1439
1554
  if (stopBtn) stopBtn.hidden = !working;
1440
- $('#sb-rounds').textContent = working ? 'pensando.' : 'listo';
1555
+ $('#sb-rounds').textContent = working ? '' : 'listo';
1441
1556
  $('#sb-rounds').classList.toggle('running', working);
1557
+ renderFooterActivity(working ? 'thinking' : null);
1442
1558
  }
1443
1559
  }
1444
1560
 
@@ -3096,6 +3212,7 @@ function setExplorerOpen(open) {
3096
3212
  state.explorerOpen = open;
3097
3213
  explorer.hidden = !open;
3098
3214
  panels.explorer.resizer.hidden = !open;
3215
+ if (!open) restorePreviewMax?.();
3099
3216
  const handle = $('#explorer-handle');
3100
3217
  if (handle) {
3101
3218
  handle.hidden = open;
@@ -3363,14 +3480,23 @@ function setPreviewStatus(text, isError = false) {
3363
3480
  }
3364
3481
 
3365
3482
  /* Ancho por dispositivo + maximizar, compartido por preview y visor de archivos. */
3366
- function setupPreviewTools({ container, stage, frame, select, maxButton }) {
3483
+ function setupPreviewTools({ container, stage, frame, select, maxButton, teleport = false }) {
3367
3484
  const applyDevice = () => {
3368
3485
  const value = select?.value || 'auto';
3369
3486
  if (stage) stage.dataset.device = value;
3370
3487
  frame.style.width = value === 'auto' ? '100%' : `${value}px`;
3371
3488
  };
3372
3489
  select?.addEventListener('change', applyDevice);
3490
+ // El panel del explorador usa `backdrop-filter`, que crea un containing block
3491
+ // y dejaria el `position: fixed` encerrado en la barra. Al maximizar movemos
3492
+ // el contenedor a `body` y lo devolvemos a su lugar al restaurar.
3493
+ const home = teleport ? container?.parentNode : null;
3494
+ const homeAfter = teleport ? container?.nextSibling : null;
3373
3495
  const setMax = (on) => {
3496
+ if (teleport && container && home) {
3497
+ if (on) document.body.appendChild(container);
3498
+ else if (!home.contains(container)) home.insertBefore(container, homeAfter ?? null);
3499
+ }
3374
3500
  container?.classList.toggle('preview-maximized', on);
3375
3501
  maxButton?.setAttribute('aria-pressed', String(on));
3376
3502
  if (maxButton) maxButton.title = on ? 'restaurar' : 'maximizar';
@@ -3388,7 +3514,9 @@ const previewTools = setupPreviewTools({
3388
3514
  frame: previewFrame,
3389
3515
  select: $('#preview-device'),
3390
3516
  maxButton: $('#preview-max'),
3517
+ teleport: true,
3391
3518
  });
3519
+ restorePreviewMax = () => previewTools.setMax(false);
3392
3520
  previewTools.applyDevice();
3393
3521
 
3394
3522
  function loadPreview(url) {
@@ -33,7 +33,7 @@
33
33
  </head>
34
34
  <body>
35
35
  <header class="topbar">
36
- <div class="brand"><span class="logo"><span class="logo-t">T</span><span class="logo-h">H</span></span> <span class="ver">v0.7.0</span></div>
36
+ <div class="brand"><span class="logo"><span class="logo-t">T</span><span class="logo-h">H</span></span> <span class="ver">v0.7.1</span></div>
37
37
  <nav class="tabs">
38
38
  <button class="tab active" data-tab="chat">chat</button>
39
39
  <button class="tab" data-tab="providers">proveedores</button>
@@ -116,6 +116,18 @@
116
116
  </div>
117
117
  </div>
118
118
  </div>
119
+ <div id="scroll-nav" class="scroll-nav" hidden>
120
+ <button id="scroll-top" type="button" class="scroll-btn" title="ir al inicio" aria-label="ir al inicio de la conversación">
121
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
122
+ <path d="m18 15-6-6-6 6" />
123
+ </svg>
124
+ </button>
125
+ <button id="scroll-down" type="button" class="scroll-btn" title="ir al final" aria-label="ir al final de la conversación">
126
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
127
+ <path d="m6 9 6 6 6-6" />
128
+ </svg>
129
+ </button>
130
+ </div>
119
131
  </div>
120
132
  <form id="chat-form" class="composer">
121
133
  <div id="slash-menu" class="slash-menu" hidden></div>
@@ -142,6 +154,7 @@
142
154
  <span class="dot">·</span>
143
155
  <span id="sb-model">—</span>
144
156
  <span class="dot">·</span>
157
+ <span id="sb-activity" class="sb-activity" hidden></span>
145
158
  <span id="sb-rounds">listo</span>
146
159
  <span class="spacer"></span>
147
160
  <span id="sb-context" class="ctx muted" title="uso del contexto del modelo">
@@ -73,6 +73,27 @@ function inline(text) {
73
73
  return out.replace(/\uE000(\d+)\uE000/g, (_, idx) => `<code>${codes[Number(idx)]}</code>`);
74
74
  }
75
75
 
76
+ const CELL_PIPE = '';
77
+
78
+ function splitRow(line) {
79
+ const masked = line.trim().replace(/\\\|/g, CELL_PIPE);
80
+ const inner = masked.replace(/^\s*\|/, '').replace(/\|\s*$/, '');
81
+ return inner.split('|').map((cell) => cell.trim().split(CELL_PIPE).join('|'));
82
+ }
83
+
84
+ function isDelimiterRow(line) {
85
+ if (!line.includes('|')) return false;
86
+ const cells = splitRow(line);
87
+ return cells.length > 0 && cells.every((cell) => /^:?-+:?$/.test(cell));
88
+ }
89
+
90
+ function cellAlign(cell) {
91
+ if (cell.startsWith(':') && cell.endsWith(':')) return 'center';
92
+ if (cell.endsWith(':')) return 'right';
93
+ if (cell.startsWith(':')) return 'left';
94
+ return '';
95
+ }
96
+
76
97
  /** Render a Markdown string to safe HTML. */
77
98
  export function renderMarkdown(text) {
78
99
  const lines = String(text ?? '').replace(/\r\n?/g, '\n').split('\n');
@@ -133,6 +154,28 @@ export function renderMarkdown(text) {
133
154
  continue;
134
155
  }
135
156
 
157
+ if (line.includes('|') && i + 1 < lines.length && isDelimiterRow(lines[i + 1])) {
158
+ flush();
159
+ const header = splitRow(line);
160
+ const aligns = splitRow(lines[i + 1]).map(cellAlign);
161
+ i += 2;
162
+ const rows = [];
163
+ while (i < lines.length && lines[i].trim() && lines[i].includes('|')) {
164
+ rows.push(splitRow(lines[i]));
165
+ i += 1;
166
+ }
167
+ const cols = Math.max(header.length, aligns.length);
168
+ const attr = (idx) => (aligns[idx] ? ` style="text-align:${aligns[idx]}"` : '');
169
+ const cells = (row, tag) =>
170
+ Array.from({ length: cols }, (_, idx) => `<${tag}${attr(idx)}>${inline(row[idx] ?? '')}</${tag}>`).join('');
171
+ out.push(
172
+ `<div class="table-wrap"><table><thead><tr>${cells(header, 'th')}</tr></thead><tbody>` +
173
+ rows.map((row) => `<tr>${cells(row, 'td')}</tr>`).join('') +
174
+ '</tbody></table></div>'
175
+ );
176
+ continue;
177
+ }
178
+
136
179
  if (/^\s*>/.test(line)) {
137
180
  flush();
138
181
  const quote = [];
@@ -1,27 +1,28 @@
1
1
  /* tamnora console — paper / aguada theme (opencode/codex-like) */
2
2
  :root {
3
- --bg: #161712;
4
- --panel: rgba(28, 29, 23, 0.82);
5
- --rail: rgba(24, 25, 19, 0.78);
6
- --theirs: #24251d;
3
+ --bg: #101107;
4
+ --panel: rgba(21, 22, 16, 0.86);
5
+ --rail: rgba(17, 18, 12, 0.82);
6
+ --theirs: #1c1d15;
7
7
  --mine: #d9704a;
8
8
  --mine-soft: rgba(217, 112, 74, 0.12);
9
9
  --text: #e9e4d6;
10
10
  --muted: #9c9789;
11
- --border: rgba(240, 234, 219, 0.14);
11
+ --border: rgba(240, 234, 219, 0.13);
12
12
  --danger: #e0705c;
13
13
  --accent: #d9704a;
14
14
  --accent-dark: #b8542f;
15
15
  --accent-soft: rgba(217, 112, 74, 0.18);
16
16
  --ok: #8fae7f;
17
17
  --warn: #d9a441;
18
- --solid: #1b1c16;
18
+ --solid: #12130b;
19
19
  --primary-fg: #fff7ef;
20
20
  --sans: Manrope, system-ui, "Segoe UI", Arial, sans-serif;
21
21
  --serif: "Libre Baskerville", Georgia, "Times New Roman", serif;
22
22
  --mono: "DM Mono", ui-monospace, "Cascadia Code", "JetBrains Mono", "SF Mono", Consolas, monospace;
23
23
  --font: var(--sans);
24
- --page-bg: linear-gradient(rgba(22, 23, 15, 0.94), rgba(22, 23, 15, 0.94)), url("/img/fondo-agentes.webp") center/cover fixed no-repeat;
24
+ --chat-font: 14.5px;
25
+ --page-bg: linear-gradient(rgba(11, 12, 8, 0.96), rgba(11, 12, 8, 0.96)), url("/img/fondo-agentes.webp") center/cover fixed no-repeat;
25
26
  color-scheme: dark;
26
27
  }
27
28
 
@@ -214,6 +215,7 @@ main { flex: 1; min-height: 0; min-width: 0; display: flex; }
214
215
  /* chat -------------------------------------------------------------------- */
215
216
  .sidebar {
216
217
  width: 260px;
218
+ flex: 0 0 auto;
217
219
  border-right: 1px solid var(--border);
218
220
  background: var(--rail);
219
221
  backdrop-filter: blur(14px);
@@ -300,10 +302,9 @@ main { flex: 1; min-height: 0; min-width: 0; display: flex; }
300
302
  }
301
303
  .rail-exit:hover { color: var(--accent); }
302
304
 
303
- .conversation { flex: 1; display: flex; flex-direction: column; min-width: 0; }
304
- .panes { flex: 1; min-height: 0; display: flex; min-width: 0; }
305
+ .conversation { flex: 1; display: flex; flex-direction: column; min-width: 0; }.panes { flex: 1; min-height: 0; display: flex; min-width: 0; position: relative; }
305
306
  .panes > .messages { flex: 1; min-height: 0; min-width: 0; }
306
- .messages { flex: 1; overflow-y: auto; overflow-x: hidden; min-width: 0; padding: 20px 24px 28px; display: flex; flex-direction: column; gap: 20px; }
307
+ .messages { flex: 1; overflow-y: auto; overflow-x: hidden; min-width: 0; padding: 20px 24px 28px; display: flex; flex-direction: column; gap: 20px; font-size: var(--chat-font); }
307
308
 
308
309
  /* resizable gutters -------------------------------------------------------- */
309
310
  .resizer {
@@ -317,6 +318,36 @@ main { flex: 1; min-height: 0; min-width: 0; display: flex; }
317
318
  body.resizing { cursor: col-resize; user-select: none; }
318
319
  body.resizing * { cursor: col-resize !important; }
319
320
 
321
+ /* scroll rápido de la conversación ---------------------------------------- */
322
+ .scroll-nav {
323
+ position: absolute;
324
+ right: 16px;
325
+ bottom: 16px;
326
+ z-index: 25;
327
+ display: flex;
328
+ flex-direction: column;
329
+ gap: 6px;
330
+ }
331
+ .scroll-nav[hidden] { display: none; }
332
+ .scroll-btn {
333
+ display: grid;
334
+ place-items: center;
335
+ width: 32px;
336
+ height: 32px;
337
+ padding: 0;
338
+ border: 1px solid var(--border);
339
+ border-radius: 999px;
340
+ background: var(--solid, var(--panel));
341
+ color: var(--muted);
342
+ cursor: pointer;
343
+ backdrop-filter: blur(14px);
344
+ box-shadow: 0 6px 18px rgb(20 18 12 / 0.22);
345
+ transition: color 0.12s ease, border-color 0.12s ease, opacity 0.12s ease;
346
+ }
347
+ .scroll-btn svg { width: 16px; height: 16px; display: block; }
348
+ .scroll-btn:hover { color: var(--accent); border-color: var(--accent); }
349
+ .scroll-btn:disabled { opacity: 0.35; cursor: default; border-color: var(--border); color: var(--muted); }
350
+
320
351
  /* session tabs ------------------------------------------------------------- */
321
352
  .tabbar {
322
353
  display: flex;
@@ -402,7 +433,6 @@ body.resizing * { cursor: col-resize !important; }
402
433
  }
403
434
  .msg-mode[data-mode='plan'] { color: var(--warn); }
404
435
  .bubble {
405
- max-width: 900px;
406
436
  min-width: 0;
407
437
  padding-left: 12px;
408
438
  border-left: 2px solid var(--border);
@@ -411,21 +441,58 @@ body.resizing * { cursor: col-resize !important; }
411
441
  overflow-wrap: anywhere;
412
442
  }
413
443
  .msg.user .bubble { border-left-color: var(--mine); color: var(--text); }
414
- .msg-actions { display: flex; gap: 6px; margin-top: 7px; opacity: 0.45; transition: opacity 0.12s ease; }
415
- .msg:hover .msg-actions,
416
- .msg:focus-within .msg-actions { opacity: 1; }
417
- .msg-actions button.msg-act {
444
+ .msg-actions { position: relative; display: flex; gap: 6px; margin-top: 7px; }
445
+ .msg-menu-btn {
446
+ display: inline-grid;
447
+ place-items: center;
448
+ width: auto;
449
+ min-height: 24px;
450
+ padding: 2px 9px;
418
451
  background: transparent;
419
452
  border: 1px solid var(--border);
420
453
  border-radius: 7px;
421
454
  color: var(--muted);
455
+ cursor: pointer;
456
+ font: 500 11px var(--mono);
457
+ opacity: 0.45;
458
+ transition: opacity 0.12s ease;
459
+ }
460
+ .msg:hover .msg-menu-btn,
461
+ .msg:focus-within .msg-menu-btn,
462
+ .msg-menu-btn[aria-expanded='true'] { opacity: 1; }
463
+ .msg-menu-btn:hover,
464
+ .msg-menu-btn[aria-expanded='true'] { color: var(--text); border-color: var(--accent); }
465
+ .msg-menu {
466
+ position: fixed;
467
+ top: 0;
468
+ left: 0;
469
+ z-index: 60;
470
+ min-width: 170px;
471
+ display: flex;
472
+ flex-direction: column;
473
+ gap: 2px;
474
+ padding: 5px;
475
+ border: 1px solid var(--border);
476
+ border-radius: 10px;
477
+ background: var(--panel);
478
+ box-shadow: 0 12px 30px rgb(0 0 0 / 0.22);
479
+ }
480
+ .msg-menu[hidden] { display: none; }
481
+ .msg-actions button.msg-act {
482
+ width: 100%;
483
+ text-align: left;
484
+ background: transparent;
485
+ border: 1px solid transparent;
486
+ border-radius: 7px;
487
+ color: var(--muted);
422
488
  font: 500 11px var(--mono);
423
- padding: 2px 8px;
489
+ padding: 5px 8px;
424
490
  cursor: pointer;
425
491
  }
426
- .msg-actions button.msg-act:hover { color: var(--text); border-color: var(--accent); }
427
- .msg-actions button.msg-act.danger:hover { color: var(--danger); border-color: var(--danger); }
428
- .bubble .tools { margin-top: 10px; display: flex; flex-direction: column; gap: 6px; }
492
+ .msg-actions button.msg-act:hover { color: var(--text); background: var(--theirs); }
493
+ .msg-actions button.msg-act.danger { color: var(--danger); }
494
+ .msg-actions button.msg-act.danger:hover { background: var(--theirs); }
495
+ .bubble .tools { margin-top: 10px; display: flex; flex-direction: column; gap: 6px; font-weight: 700; }
429
496
 
430
497
  .tools-actions { display: flex; align-items: center; gap: 8px; }
431
498
  .tools-actions[hidden] { display: none; }
@@ -573,6 +640,11 @@ body.resizing * { cursor: col-resize !important; }
573
640
  .activity .loader { color: var(--accent); }
574
641
  .activity-label { color: var(--muted); }
575
642
  #sb-rounds.running { color: var(--accent); }
643
+ .sb-activity { display: inline-flex; align-items: center; gap: 6px; }
644
+ .sb-activity .loader { color: var(--accent); }
645
+ .sb-activity .loader-thinking { width: 24px; height: 6px; }
646
+ .sb-activity .loader-working { width: 13px; height: 13px; }
647
+ .sb-activity .activity-label { color: var(--muted); }
576
648
 
577
649
  .loader-thinking {
578
650
  width: 32px;
@@ -671,6 +743,16 @@ details.reasoning .body { color: var(--muted); white-space: pre-wrap; margin-top
671
743
  .text blockquote { margin: 0 0 10px; padding-left: 12px; border-left: 2px solid var(--border); color: var(--muted); }
672
744
  .text a { color: var(--accent); }
673
745
  .text hr { border: 0; border-top: 1px solid var(--border); margin: 14px 0; }
746
+ .text .table-wrap, .agent-text .table-wrap { overflow-x: auto; margin: 0 0 10px; }
747
+ .text table, .agent-text table { border-collapse: collapse; width: 100%; font-size: 0.95em; }
748
+ .text th, .text td, .agent-text th, .agent-text td {
749
+ border: 1px solid var(--border);
750
+ padding: 5px 10px;
751
+ text-align: left;
752
+ vertical-align: top;
753
+ }
754
+ .text th, .agent-text th { background: var(--theirs); font-weight: 600; }
755
+ .text .table-wrap code, .agent-text .table-wrap code { white-space: nowrap; }
674
756
  .text code {
675
757
  background: var(--theirs);
676
758
  border: 1px solid var(--border);
@@ -687,7 +769,7 @@ details.reasoning .body { color: var(--muted); white-space: pre-wrap; margin-top
687
769
  .code-copy { background: transparent; border: 0; color: var(--muted); cursor: pointer; font: inherit; font-size: 11px; }
688
770
  .code-copy:hover { color: var(--text); }
689
771
  pre.code { margin: 0; padding: 10px 12px; overflow: auto; max-height: 420px; }
690
- pre.code code { background: none; border: 0; padding: 0; font-size: 12.5px; }
772
+ pre.code code { background: none; border: 0; padding: 0; font-size: 0.92em; }
691
773
  .tok-comment { color: #8c8779; font-style: italic; }
692
774
  .tok-string { color: #c2a878; }
693
775
  .tok-number { color: #e0a05c; }
@@ -1059,6 +1141,14 @@ dialog.file-modal.preview-maximized {
1059
1141
  }
1060
1142
 
1061
1143
 
1144
+ /* desktop: los laterales conservan su ancho mínimo (igual que el resizer) */
1145
+ @media (min-width: 821px) {
1146
+ .sidebar { min-width: 180px; }
1147
+ }
1148
+ @media (min-width: 901px) {
1149
+ .explorer { min-width: 200px; }
1150
+ }
1151
+
1062
1152
  @media (max-width: 900px) {
1063
1153
  .explorer {
1064
1154
  position: fixed;
@@ -31,6 +31,39 @@ test('only allows http(s) links', () => {
31
31
  assert.ok(!renderMarkdown('[x](javascript:alert(1))').includes('<a '));
32
32
  });
33
33
 
34
+ test('renders pipe tables with alignment and stops at the next block', () => {
35
+ const md = [
36
+ '| Archivo | Qué es | Centro | Derecha |',
37
+ '| --- | :-- | :-: | --: |',
38
+ '| index.html | Landing | x | y |',
39
+ '| `a.js` | **script** | z | w |',
40
+ '',
41
+ 'sigue el texto',
42
+ ].join('\n');
43
+ const html = renderMarkdown(md);
44
+ assert.match(html, /<div class="table-wrap"><table>/);
45
+ assert.match(html, /<th>Archivo<\/th>/);
46
+ assert.match(html, /<th style="text-align:center">Centro<\/th>/);
47
+ assert.match(html, /<th style="text-align:right">Derecha<\/th>/);
48
+ assert.match(html, /<td style="text-align:left">Landing<\/td>/);
49
+ assert.match(html, /<td><code>a\.js<\/code><\/td>/);
50
+ assert.match(html, /<td style="text-align:left"><strong>script<\/strong><\/td>/);
51
+ assert.match(html, /<\/tbody><\/table><\/div>\n<p>sigue el texto<\/p>/);
52
+ });
53
+
54
+ test('table cells are escaped and escaped pipes stay in one cell', () => {
55
+ const html = renderMarkdown('| col | col2 |\n| --- | --- |\n| a \\| b | <script> |');
56
+ assert.match(html, /<td>a \| b<\/td>/);
57
+ assert.ok(!html.includes('<script>'));
58
+ assert.match(html, /&lt;script&gt;/);
59
+ });
60
+
61
+ test('a lone pipe line without a delimiter row stays a paragraph', () => {
62
+ const html = renderMarkdown('foo | bar');
63
+ assert.ok(!html.includes('<table'));
64
+ assert.equal(html, '<p>foo | bar</p>');
65
+ });
66
+
34
67
  test('highlight escapes code content', () => {
35
68
  assert.equal(highlight('a < b', 'bash'), 'a &lt; b');
36
69
  });
@@ -49,6 +49,7 @@ test('serves health, providers and the console', async () => {
49
49
  'id="settings-controls-slot"',
50
50
  'id="slash-menu"',
51
51
  'id="statusbar"',
52
+ 'id="sb-activity"',
52
53
  ]) {
53
54
  assert.ok(html.includes(hook), `falta ${hook}`);
54
55
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danieltmn/tamnora",
3
- "version": "0.7.0",
3
+ "version": "0.7.1",
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",