@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 +26 -0
- package/harness/packages/server/public/app.js +151 -23
- package/harness/packages/server/public/index.html +14 -1
- package/harness/packages/server/public/markdown.js +43 -0
- package/harness/packages/server/public/styles.css +110 -20
- package/harness/packages/server/test/markdown.test.js +33 -0
- package/harness/packages/server/test/server.test.js +1 -0
- package/package.json +1 -1
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
|
-
|
|
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,
|
|
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
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
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
|
-
|
|
861
|
-
|
|
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
|
-
|
|
864
|
-
|
|
970
|
+
footerActivityEl.hidden = true;
|
|
971
|
+
footerActivityEl.replaceChildren();
|
|
865
972
|
return;
|
|
866
973
|
}
|
|
867
|
-
const def = ACTIVITY[kind];
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
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 ? '
|
|
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 ? '
|
|
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.
|
|
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: #
|
|
4
|
-
--panel: rgba(
|
|
5
|
-
--rail: rgba(
|
|
6
|
-
--theirs: #
|
|
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.
|
|
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: #
|
|
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
|
-
--
|
|
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;
|
|
415
|
-
.msg
|
|
416
|
-
|
|
417
|
-
|
|
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:
|
|
489
|
+
padding: 5px 8px;
|
|
424
490
|
cursor: pointer;
|
|
425
491
|
}
|
|
426
|
-
.msg-actions button.msg-act:hover { color: var(--text);
|
|
427
|
-
.msg-actions button.msg-act.danger
|
|
428
|
-
.
|
|
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:
|
|
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, /<script>/);
|
|
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 < b');
|
|
36
69
|
});
|
package/package.json
CHANGED